티스토리 뷰

✔️ N+1 문제란?

N+1 문제는 JPA에서 Entity를 조회할 때 연관 관계가 있는 경우, 연관 관계가 있는 데이터 개수만큼 N번 쿼리를 추가로 반복한다.

아주 무서운 쿼리

 

여기서 JPA는 Java Persistence API로 Java 애플리케이션에서 DB와 Entity(객체)를 매핑해주는 ORM(Object Relational Mapping) 기술이다. 음 이렇게 정의만 하면 모호할 수 있는데, Spring boot는 Hibernate를 주로 쓴다. (Hibernate는 JPA  중 하나)

 

JPA 예를 들자면 아래와 같은 entity가 있다면

@Entity
public class User{
	@Id
    private Long id;
    
    private String name;
}

 

아래와 같이 Repository를 적기만 해주면 findAll()이나 findById() 같은 개발자가 직접 db에 데이터를 찾는 SQL Query를 짜지 않고 데이터를 찾아올 수 있는 것이다. 당연히 CRUD 다 포함한다.

public interface UserRepository extends JpaRepository<User, Long> {
    
}

 

Hibernate는 JPA의 구현체인데, JPA가 인터페이스라면 Hibernate는 구현한 라이브러리다.

@Entity나 @Column, @Id와 같은 어노테이션이  있으면, 자바 클래스나 필드가 있으면 DB 테이블이나 컬럼을 매핑해준다. 

즉, 위에 있는 Entity처럼 @Entity, @Id 어노테이션 처리를 해줬으니까 DB에 있는 테이블과 연결되어 CRUD(Insert, Delete, Update, Select)를 자동으로 할 수 있는거다.

Hibernate도 SQL를 직접 쓰지 않아도, 실행하게 할 수 있다. 

객체를 생성하고, persist()를 해주면 user라는 객체가 자동으로 DB에 Insert 된다.

User user = new User("TOM");
entityManager.persist(user);

 

 

✔️ N+1의 예시

아무튼 설명은 뒤로하고 다시 와서, N+1의 예시를 들어본다.

음... 학과:학생 = 1: N 관계로 매핑했다고 하자.

즉, 학과에 여러 학생이 넣어질 수 있다는 것이다.

학과 4개를 생성하여 넣은 상태이다.

 

- Entity

- 학과

@Entity
public class Department {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    //생략
    
    // 1:N 관계
    @OneToMany(fetch = FetchType.LAZY,
                mappedBy = "department",
                cascade = CascadeType.ALL)

 

 

- departmentService

departmentService에서 모든 학과를 조회하는 getAllDepartments를 실행하면 어떻게 될까?

findAll()에서는 1번의 quary가 실행된다.

하지만 Long studentCount = studentRepository.countByDepartmentId(department.getId());에서

countByDepartmentId()가 학과마다 한 번씩 호출된다.

즉 내가 미리 넣은 학과는 4개니까, findAll quary 1번, countByDepartmentId quary가 4번 더 수행된다.

public List<DepartmentDTO.SimpleResponse> getAllDepartments() {
        List<Department> departments = departmentRepository.findAll();

        return departments.stream()
                .map(department -> {
                    Long studentCount = studentRepository.countByDepartmentId(department.getId());
                    return DepartmentDTO.SimpleResponse.builder()
                            .id(department.getId())
                            .name(department.getName())
                            .code(department.getCode())
                            .studentCount(studentCount)
                            .build();
                })
                .toList();
    }

 

 

수행된 모습

Hibernate: select d1_0.department_id,d1_0.code,d1_0.name from departments d1_0
Hibernate: select count(s1_0.student_id) from students s1_0 where s1_0.department_id=?
Hibernate: select count(s1_0.student_id) from students s1_0 where s1_0.department_id=?
Hibernate: select count(s1_0.student_id) from students s1_0 where s1_0.department_id=?
Hibernate: select count(s1_0.student_id) from students s1_0 where s1_0.department_id=?

 

 

❓이게 왜 문제가 되는가

지금으로 봐선 뭐, 쿼리 4개 더 실행되네 라고 지나갈 수 있다.

하지만 학과가 100개가 넘어갈 때는 101개의 쿼리가 실행되는거다.

그리고 만약 학과에 N:1의 관계의 객체가 Entity요소로 하나 더 추가된다면, 그것도 학과만큼 쿼리를 돌릴거다.

즉 데이터가 늘어날수록 데이터 수만큼 쿼리를 반복되고

성능이 저하되며 DB 부하가 증가할 거다.

 

 

✔️ 해결방법

1. Join으로 한 번에 가져오기

위 예시는 Repository에서 department정보를 가져오고, User에 대한 정보는 service에서 따로 가져왔다.

근데, 이를 department 정보를 가져올 때 User의 정보를 한 번에 같이 가져오는 것이다.

JPQL를 작성할 때 SQL Join을 하여 연관된 정보를 모두 가져온다.

당연히 Repository에 별도의 메소드를 만들어줘야하고 Query 어노테이션을 사용해야한다.

사실 이건 내가 student count 정보를 가져와야해서 이렇게 복잡해졌다.

@Query("SELECT new com.example.dto.DepartmentStudentCountDto(d.id, d.name, d.code, COUNT(s)) " +
       "FROM Department d LEFT JOIN Student s ON s.department = d " +
       "GROUP BY d.id, d.name, d.code")
List<DepartmentStudentCountDto> findDepartmentsWithStudentCount();

 

2. 배치 페치(batch fetching) 전략  : Batch Size (추천⭐)

이 옵션은 1번 방법처럼 쿼리 한 번에 수행하게 하는 방법은 아니다.

SELECT * FROM department where user_id=?; 이런 쿼리가 N개 반복되는 대신

SELECT * FROM department where user_id in (?, ?, ?)로 in을 사용하여 1+1 = 총 2번의 쿼리 작동만으로 성능을 최적화하는 방법이다.

아주 간단해서 이 방법을 추천한다.

application.properties에 아래와 같은 코드를 추가해주면 된다.

1000이라 설정해 준 것은 In절의 최대 개수 설정이다.

즉, 엔티티를 조회할 때 최대 1000개씩 한꺼번에 묶어서 한 번에 배치조회하는 거다.

대신 FetchType을 EAGER가 아닌, LAZY를 써줘야 효과가 있다는 점😎

spring.jpa.properties.hibernate.default_batch_fetch_size=1000

 

 

✔️EAGER VS LAZY (열정적인! vs 게으른!)

- EAGER (즉시로딩)

차이는 엔티티를 호출할 때 관련 엔티티를 즉시 불러오느냐 마느냐의 차이가 있다.

EAGER은 해당 엔티티를 조회할 때 연관된 엔티티도 함께 즉시 조회한다.

예를 들면 user entity에 department entity가 n:1관계로 묶여있다고 하자.

이때 user entity에 EAGER 설정을 해준다면, user entity가 호출될 때마다 department entity도 무조건 함께 호출된다.

즉 department entity가 필요없고 user entity만 필요할 때에도 무조건 함께 호출되는 단점이 있다.

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name="department_id")
private Department department;

 

- LAZY (지연로딩)

그럴때 LAZY를 써준다.

LAZY는 연관된 엔티티가 실제로 사용할 때만 조회한다.

즉 user entity를 호출할 때, department entity는 자동으로 호출되지 않는다.

따라서 user entity와 department entity를 함께 호출하고 싶다면 department entity를 호출해주는 코드를 적어줘야한다.

 

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="department_id")
private Department department;

 

그렇다면

spring.jpa.properties.hibernate.default_batch_fetch_size=1000 이 코드는 왜 FetchType가 LAZY일때만 효과가 있을까?

EAGER은 개발자가 코드를 작성해주지 않아도 엔티티와 관련있는 엔티티를 즉시 함께 호출한다고 했었다.

따라서 배치 페치(batch fetching) 전략이 개입할 여지 없이, 이미 조인이나 즉시 쿼리로 연관된 데이터가 모두 로딩된다.

LAZY는 필요할 때만 호출하니, 개입할 여지가 있다.

default_batch_fetch_size를 설정하면, Hibernate가 LAZY 상태의 연관 엔티티들을 접근할 때 

IN절을 사용해 한 번에 여러 개를 조회하도록 한다.

반응형
LIST
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함
반응형