JPA N+1 문제와 해결

배태현·2021년 11월 16일
5

JPA

목록 보기
7/7
post-thumbnail

제 글에 문제가 있다면 댓글로 알려주시면 감사하겠습니다 ! 🙇‍♂️

저는 JPA를 이용하며 프로젝트를 하며 N+1 문제를 만나보진 않았습니다.
하지만 JPA를 사용하면 자주 만나게 되는 것이 N+1 문제입니다.

JPA를 공부 할 때에 예시 상황을 만들고 공부를 했었지만
공부한지 시간이 좀 지났기 때문에 프로젝트 중 N+1 문제가 생기더라도
당황하지 않고 빠르게 해결하기 위해, 다시 공부하기 위해 이 글을 작성합니다.

이동욱님의 글을 참고하여
Java8 -> Java11,
SpringBoot 1.5.X -> 2.5.X,
Junit4 -> Junit5로 버전을 업그레이드하여
코드를 작성하였고, 이 글을 작성하였습니다
참고하였습니다 🙇‍♂️

모든 코드는 Github에 있습니다.

먼저 클래스 다이어그램과 ERD 입니다.

클래스 다이어그램, ERD

위와 같은 구조에서 Academy를 호출하여 그 안에 속한 Subject를 사용한다고 가정해보겠습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class AcademyService {

    private final AcademyRepository academyRepository;

    @Transactional(readOnly = true)
    public List<String> findAllSubjectNames(){
        return extractSubjectNames(academyRepository.findAll());
    }

    /**
     * Lazy Load를 수행하기 위해 메소드를 별도로 생성
     */
    private List<String> extractSubjectNames(List<Academy> academies){
        log.info(">>>>>>>>[모든 과목을 추출한다]<<<<<<<<<");
        log.info("Academy Size : {}", academies.size());

        return academies.stream()
                .map(a -> a.getSubjects().get(0).getName())
                .collect(Collectors.toList());
    }
}

여기서 서비스의 findAllSubjectNames를 호출하면 어떤 일이 발생하는지
테스트코드를 작성하여 쿼리가 어떻게 생성되는지 확인해보겠습니다.

@Commit
@SpringBootTest
class AcademyServiceTest {

    @Autowired private AcademyRepository academyRepository;
    @Autowired private TeacherRepository teacherRepository;
    @Autowired private AcademyService academyService;

    @BeforeEach
    public void setup() {
        List<Academy> academies = new ArrayList<>();

        Teacher teacher = teacherRepository.save(new Teacher("선생님"));

        for(int i=0;i<10;i++){
            Academy academy = Academy.builder()
                    .name("강남스쿨"+i)
                    .build();

            academy.addSubject(Subject.builder().name("자바웹개발" + i).teacher(teacher).build());
            academy.addSubject(Subject.builder().name("파이썬자동화" + i).teacher(teacher).build()); // Subject를 추가
            academies.add(academy);
        }
        academyRepository.saveAll(academies);
    }

      @Test
      public void Academy여러개를_조회시_Subject가_N1_쿼리가발생한다() throws Exception {
          //given
          List<String> subjectNames = academyService.findAllSubjectNames();

          //then
          assertEquals(10, subjectNames.size());
      }
    }

위의 테스트코드를 실행보면

DB에는 이렇게 저장되고

전체 조회하는 쿼리 1개
각각의 Academy가 본인들의 subject를 조회하는 쿼리 10개가 발생한 것을 확인할 수 있습니다.

이렇게 하위 엔티티들을 첫 쿼리 실행 시 한번에 가져오지 않고,
LAZY로딩(지연로딩)으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제,
1개의 쿼리로 인해 N개의 쿼리가 더 나가는 상황N+1 문제라고 합니다.
(1+N이 더 맞는 것 같은데 왜 N+1인지 모르겠네요😝)

현재 상황에서는 Academy가 10개로
(첫 조회 쿼리 1개) + (Academy의 subject 10개 조회 쿼리 10개) = 11밖에 발생하지 않았지만,
만약 Academy 조회 결과가 10만개라면 한번의 서비스 로직을 실행하는데 DB 조회 쿼리가 10만번이 발생하는 상황 입니다.

그래서 이렇게 연관관계가 맺어진 Entity를 한번에 가져오기 위한 몇가지 방법들이 있습니다.

1. Join Fetch

첫번 째 방법은 join fetch를 사용하는 방법입니다.

    /**
     * 1. join fetch를 통한 조회
     */
    @Query("select a from Academy a join fetch a.subjects")
    List<Academy> findAllJoinFetch();

조회 시 바로 가져오고 싶은 Entity 필드를 지정(join fetch a.subjects)하는 것 입니다.
이렇게 바꾼 후 테스트코드를 실행하면

이렇게 하나의 쿼리로 모두 조회할 수 있습니다.

만약 Subject의 하위 Entity까지 한번에 가져와야 할 때에도
아래와 같은 방법으로 쉽게 해결할 수 있습니다.

    /**
     * 5. Academy+Subject+Teacher를 join fetch로 조회
     */
    @Query("select distinct a from Academy a join fetch a.subjects s join fetch s.teacher")
    List<Academy> findAllWithTeacher();

a.subjectss로 alias하여 s의 teacherjoin fetch 하면 한번에 가져올 수 있습니다.

단, 이 방법은 불필요한 쿼리문이 추가되는 단점이 있습니다.

이 필드는 Eager 조회, 저 필드는 Lazy 조회를 해야한다까지
쿼리에서 표현하는 것은 불필요하다라고 생각하실 분들이 계실 수 있습니다.

그런 분들은 2번 방법을 사용해보시면 좋을 것 같습니다.

2. @EntityGraph

두번 째 방법은 @EntityGraph를 사용하는 방법입니다.

    /**
     * 2. @EntityGraph
     */
    @EntityGraph(attributePaths = "subjects")
    @Query("select a from Academy a")
    List<Academy> findAllEntityGraph();

@EntityGraphattributePaths에 쿼리 수행 시
바로 가져올 필드명을 지정하면 LAZY(지연로딩)가 아닌 Eager(즉시로딩) 조회로 가져오게 됩니다.

위처럼 원본 쿼리의 손상 없이 EAGER/LAZY 필드를 정의하고 사용할 수 있게 되었습니다.
추가로 Teacher까지 한번에 가져오는 쿼리도 아래와 같이 표현할 수 있습니다.

    /**
     * 6. Academy+Subject+Teacher를 @EntityGraph 로 조회
     */
    @EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
    @Query("select DISTINCT a from Academy a")
    List<Academy> findAllEntityGraphWithTeacher();

🚨 사용 시 주의사항

Join Fetch

SELECT academy0_.id          AS id1_0_0_, 
       subjects1_.id         AS id1_1_1_, 
       academy0_.name        AS name2_0_0_, 
       subjects1_.academy_id AS academy_3_1_1_, 
       subjects1_.name       AS name2_1_1_, 
       subjects1_.teacher_id AS teacher_4_1_1_, 
       subjects1_.academy_id AS academy_3_1_0__, 
       subjects1_.id         AS id1_1_0__ 
FROM   academy academy0_ 
       INNER JOIN subject subjects1_ 
               ON academy0_.id = subjects1_.academy_id 

@EntityGraph

SELECT academy0_.id          AS id1_0_0_, 
       subjects1_.id         AS id1_1_1_, 
       academy0_.name        AS name2_0_0_, 
       subjects1_.academy_id AS academy_3_1_1_, 
       subjects1_.name       AS name2_1_1_, 
       subjects1_.teacher_id AS teacher_4_1_1_, 
       subjects1_.academy_id AS academy_3_1_0__, 
       subjects1_.id         AS id1_1_0__ 
FROM   academy academy0_ 
       LEFT OUTER JOIN subject subjects1_ 
                    ON academy0_.id = subjects1_.academy_id 

JoinFetchInner Join, @EntityGraphOuter Join이라는 차이점이 있습니다.

공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수만큼 Academy가 중복 발생하게 됩니다.

확인을 위해 테스트 코드를 작성해보면 아래와 같습니다.

@BeforeEach
    public void setup() {
        List<Academy> academies = new ArrayList<>();

        Teacher teacher = teacherRepository.save(new Teacher("선생님"));

        for(int i=0;i<10;i++){
            Academy academy = Academy.builder()
                    .name("강남스쿨"+i)
                    .build();

            academy.addSubject(Subject.builder().name("자바웹개발" + i).teacher(teacher).build());
            academy.addSubject(Subject.builder().name("파이썬자동화" + i).teacher(teacher).build()); // Subject를 추가
            academies.add(academy);
        }
        academyRepository.saveAll(academies);
        System.out.println("====================save all====================");
    }

    @Test
    public void Academy여러개를_joinFetch로_가져온다() throws Exception {
        //given
        List<Academy> academies = academyRepository.findAllJoinFetch();
        List<String> subjectNames = academyService.findAllSubjectNamesByJoinFetch();

        //then
        assertEquals(20, academies.size()); // 20개가 조회!?
        assertEquals(20, subjectNames.size()); // 20개가 조회!?

        // JoinFetch는 InnerJoin, Entity Graph는 Outer Join
        // 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수 만큼 Academy가 중복발생하게 됩니다.
        // 그래서 20개가 조회되는 것 입니다.
    }

해결 방안

두가지 방법이 있습니다.

  • 먼저 첫번 째 방법은 필드의 타입을 Set으로 선언하는 것입니다.
    (Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않는 점을 이용합니다.)
    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name="academy_id")
    private Set<Subject> subjects = new LinkedHashSet<>();

(Set은 순서가 보장되지 않기에 LinkedHashSet을 사용하여 순서를 보장합니다.)

  • 두번 째 방법은 DISTINCT를 사용하여 중복을 제거하는 것 입니다.
    (Set보다는 List가 적합하다고 판단될 때)
    이 방법은 쿼리에 적용하는 방법이라 join fetch, @EnityGraph 모두 동일하게 사용됩니다.
    /**
     * DISTINCT + join fetch
     */
     @Query("select DISTINCT a from Academy a join fetch a.subjects s join fetch s.teacher")
     List<Academy> findAllWithTeacher();
    /**
     * DISTINCT + @EntityGraph
     */
     @EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
     @Query("select DISTINCT a from Academy a")
     List<Academy> findAllEntityGraphWithTeacher();

두가지 방법 중 상황에 맞게 사용하시면 될 것 같습니다.

@NamedEntityGraphs

N+1 문제 해결을 얘기할때 @NamedEntityGraphs가 예시로 많이 등장하곤하는데

@NamedEntityGraphs의 경우 Entity에 관련해서 모든 설정 코드를 추가해야하는데,
이동욱님 생각엔 Entity가 해야하는 책임에 포함되지 않는다고 생각하신다고 합니다.

A 로직에서는 Fetch전략을 어떻게 가져가야 한다는 것은 해당 로직의 책임이지, Entity의 책임이 아니다.
Entity에선 실제 도메인에 관련 된 코드만 작성하고,
상황에 따라 유동적인 Fetch 전략을 가져가는 것은 전적으로 서비스/레파지토리에서 결정해야하는 일.

마무리

이렇게 한번 더 공부 함으로써 N+1 문제에 대해 더 확실히 알게되고
프로젝트 중 N+1 문제가 생기더라도 당황하지 않고 해결할 수 있을 것 같습니다
글 읽어주셔서 감사합니다 😊

profile
일상의 불편함을 기술로 해결 할 방법을 고안합니다.

0개의 댓글