ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JPA] N+1 문제 해결하기
    Spring 2020. 12. 25. 12:01
    반응형
    안녕하세요.
    오늘 알아볼 주제는 OneToOne 또는 ManyToOne 관계에서 발생하는 N + 1 문제 해결하기 입니다.
    해당 블로그의 코드는 Github에서 제공됩니다.
    XToMany 관계에서 N+1 문제 해결하기는 추후에 게시할 예정입니다.

     

    본문


    JPA를 사용하다보면, 자주 만나는 문제가 N+1 문제입니다.

    다음과 같이 하나의 부서(Department)에 여러명의 직원(Employee)이 소속되어 있다고 가정하겠습니다.

     

     

    Department와 Employee의 관계를 코드로 간단하게 표현해보겠습니다.

    Employee가 연관관계 주인으로써 조회할 때 Department를 지연로딩 합니다.

    @Getter
    @Entity
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Department {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "department_id")
        private Long id;
    
        private String name;
    
        @OneToMany(mappedBy = "department")
        private List<Employee> employees = new ArrayList<>();
    
        public Department(String name) {
            this.name = name;
        }
    }

     

    @Getter
    @Entity
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Employee {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "employee_id")
        private Long id;
    
        private String name;
    
        @ManyToOne(fetch = FetchType.LAZY)
        private Department department;
    
        public Employee(String name, Department department) {
            this.name = name;
            this.department = department;
        }
    }

     

    테스트 코드 실행에 앞서 10개의 부서와 50명의 직원을 저장합니다.

        @BeforeEach
        void setup() {
            // 부서 10개 저장
            for (int i = 0; i < 10; i++)
                departmentRepository.save(new Department("부서" + (i + 1)));
    
            List<Department> findDepartments = departmentRepository.findAll();
    
            // 직원 50개 저장
            for (int i = 0; i < 50; i++)
                employeeRepository.save(new Employee("직원" + (i + 1), findDepartments.get(i % 10)));
    
            em.flush();
            em.clear();
        }

     

    모든 직원을 조회한 후 각각의 직원의 department의 name을 조회하는 테스트 코드를 실행시키면 어떻게 될까요?

        @Test
        @DisplayName("X To One 관계에서 지연 로딩으로 조회하기")
        void v1() {
            List<Employee> findEmployees = employeeRepository.findAll();
    
            for (Employee findEmployee : findEmployees) {
                System.out.println("employee = " + findEmployee.getName());
                System.out.println("department name = " + findEmployee.getDepartment().getName());
            }
        }

    직원을 조회하는 쿼리와 부서의 이름을 가져올 때 마다 추가적으로 쿼리가 나가는 것을 볼 수 있습니다. 그 이유는 Employee Entity에서 Department의 fetch를 LAZY로 설정했기 때문에 department가 필요한 시점에 DB에 조회 쿼리를 날려 가져오는 것 입니다.

     

    그래서 위 코드는 직원을 조회하는 쿼리 1개와 부서를 조회하는 쿼리 10개 즉, 총 11개의 쿼리가 나가게 됩니다. 이게 왜 문제야? 할 수 있지만, 부서가 10개가 아니라 10만개가 있다고 가정하면 for문 한번에 10만개의 쿼리가 나가는 큰 문제로 이어집니다.

    N + 1 쿼리 발생 !!

     

     

    그럼 department의 name 대신 id를 조회하면 어떻게 될까요?

        @Test  
        void v1() {
            List<Employee> findEmployees = employeeRepository.findAll();
    
            for (Employee findEmployee : findEmployees) {
                System.out.println("findEmployee = " + findEmployee.getName());
                System.out.println("department id = " + findEmployee.getDepartment().getId());
            }
        }

     

    저희의 예상과는 달리 모든 직원을 조회하는 쿼리가 하나만 나갈 뿐 department를 조회하는 N + 1 문제가 발생하지 않습니다. 왜 그럴까요? 

    N + 1 쿼리가 발생하지 않음

     

    이유를 알기 위해서 우리는 Employee를 조회할 때 JPA가 어떻게 동작하는지 알아야 합니다.

    (동작 원리에 대해 간단하게 설명합니다.)

    1. JpaRepository를 통해서 findAll 메서드가 호출되면, Persistence Context의 1차 캐시에 Entity가 있는지 확인하고 없다면

    2. DB에 select 쿼리문을 보내고 1차 캐시에 저장 후 Entity를 반환합니다.

    3. 반환된 Employee Entity의 department를 자세히 보면 JPA가 만들어준 Proxy객체가 들어있는 것을 볼 수 있습니다.

    4. Proxy객체에는 2번에서 Employee를 조회할 때 가져온 department의 id와 빈 Department 타입의 target 필드가 있습니다. 이제 우리는 department의 id를 가져올 때 N + 1 문제가 발생하지 않은 이유를 알 수 있습니다. Proxy에 이미 id값이 있기 때문입니다.

    5. 하지만 첫 번째 테스트 코드에서 처럼 name을 가져올때는 Proxy에 name 필드가 비어있기 때문에 JPA는 target에 실제 Entity를 할당할 것을 Persistence Context에 요청을 합니다.

    6. Persistence Context의 1차 캐시에 id가 1인 Department Entity가 존재하지 않기 때문에 DB에 select 쿼리문을 보내고 1차 캐시에 저장 후 Entity를 DepartmentProxy의 target 필드에 넣어줍니다.

    7. 이후 Proxy의 target 필드로 조회된 Department Entity를 참조하여 사용합니다.

     

    N + 1 문제가 발생했을 때 처럼 JPA 트랜잭션 내에서 지연로딩이 발생할 상황이 있다면 연관관계가 맺어져 있는 Entity를 한번에 조회해야 합니다.

     

    우리는 XToOne 연관관계에서 가장 많이 사용하는 fetch join에 대해서 살펴보도록 하겠습니다.

     

    Fetch Join


    fetch join을 사용하는 방법은 간단합니다.

    @Query("select e from Employee e join fetch e.department")
    List<Employee> findAllWithDepartment();

     

    Employee의 alias를 e로 설정하고 e.department로 join fetch하여 하나의 쿼리로 조회하는 것을 확인할 수 있습니다.

     

    이렇게 지연로딩이 발생할 수 있는 상황에서 join fetch로 간단하게 N + 1 문제를 해결해 보았습니다.

     

    다음 포스팅에서는 XToMany관계에서 join fetch로 N + 1 문제를 해결할 수 없을 때 해결방법을 알아보겠습니다.

    감사합니다.

    반응형
Designed by Tistory.