JPA에서 N+1 문제란?
N+1 문제는 JPA에서 연관된 엔티티를 조회할 때 불필요한 추가 쿼리가 발생하여 성능이 저하되는 현상입니다.
- N+1: 1개의 조회 쿼리(Parent)와 연관된 N개의 추가 조회 쿼리(Child)가 발생하는 구조를 의미합니다.
- 예를 들어, 부모 엔티티를 조회할 때 연관된 자식 엔티티를 개별적으로 조회하게 되면, 부모 1개 조회 시 자식 N개만큼 추가 쿼리가 발생합니다.
발생 예시
// Parent 엔티티
@Entity
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;
}
// Child 엔티티
@Entity
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}
// Repository에서 조회
List<Parent> parents = parentRepository.findAll();
for (Parent parent : parents) {
System.out.println(parent.getChildren().size());
}
- findAll()로 부모 엔티티를 조회하면 1개의 SELECT 쿼리가 실행됩니다.
- 그 후, getChildren()을 호출할 때마다 부모 엔티티 개수만큼 자식 엔티티를 조회하는 N개의 SELECT 쿼리가 추가로 발생합니다.
총 쿼리 개수: 1 (부모 조회) + N (자식 조회) = N + 1
해결 방법
1. Fetch Join 사용하기
- JPA의 JOIN FETCH를 통해 부모와 자식을 한 번에 조회합니다.
- 조인 전략으로 한 번의 쿼리만 실행되므로 N+1 문제가 발생하지 않습니다.
- @Query("SELECT p FROM Parent p JOIN FETCH p.children") List<Parent> findAllWithChildren();
실행 쿼리:
SELECT p.*, c.* FROM Parent p
LEFT JOIN Child c ON p.id = c.parent_id
2. Entity Graph 사용하기
- Spring Data JPA의 @EntityGraph를 사용하여 명시적으로 Fetch 전략을 정의합니다.
- @EntityGraph(attributePaths = {"children"}) List<Parent> findAll();
3. Batch Size 설정하기
- Hibernate의 @BatchSize를 통해 한 번에 여러 엔티티를 조회합니다.
- 10개씩 묶어서 조회하기 때문에 불필요한 쿼리가 줄어듭니다.
- @BatchSize(size = 10) @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY) private List<Child> children;
- application.properties에 전역 설정도 가능합니다.
- spring.jpa.properties.hibernate.default_batch_fetch_size=10
4. Subselect Fetch 사용하기
- 서브쿼리를 활용하여 부모 엔티티를 조회한 후, 자식 엔티티를 한 번에 조회합니다.
- @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY) @Fetch(FetchMode.SUBSELECT) private List<Child> children;
5. 테이블 설계 변경하기 (비정규화)
- 만약 특정 연관된 데이터를 자주 조회한다면, 조인 없이 조회할 수 있도록 테이블 구조를 비정규화할 수 있습니다.
- 예를 들어, Child의 정보를 Parent에 미리 저장해 놓으면 조인 없이 조회할 수 있습니다.
비정규화된 테이블 구조 예시
CREATE TABLE parent (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
child_names VARCHAR(255) -- 자식 이름들을 쉼표로 구분하여 저장
);
JPA 조회 코드
List<Parent> parents = parentRepository.findAll();
for (Parent parent : parents) {
System.out.println(parent.getChildNames()); // 추가 쿼리 없이 조회
}
정리
Fetch Join | 한 번의 쿼리로 모든 데이터 조회 | 복잡한 조인은 페이징 불가능 |
Entity Graph | 쿼리 최적화가 쉬움 | 복잡한 구조에서는 관리가 어려움 |
Batch Size | 데이터 묶음 조회로 쿼리 최적화 | In-Memory 캐싱으로 메모리 사용 증가 |
Subselect | 서브쿼리로 한 번에 조회 | 데이터가 클 경우 성능 저하 |
비정규화 | 조회 시 조인이 없어 빠름 | 데이터 중복으로 쓰기 비용 증가 |