N+1 문제란?
N+1 문제는 JPA나 ORM을 사용할 때 발생할 수 있는 성능 문제 중 하나다. 간단히 말해, 하나의 쿼리를 실행한 후 관련된 N개의 항목에 대해 추가로 N개의 쿼리가 실행되는 상황을 말한다. 예를 들어, 한 번의 조회 쿼리로 여러 개의 부모 엔티티를 가져온 후, 각 부모 엔티티와 관련된 자식 엔티티를 조회하기 위해 N번의 추가 쿼리가 실행되는 경우가 이에 해당한다.
N+1 문제 예시
Entity Class
@Entity
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;
// getters and setters
}
@Entity
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
// getters and setters
}
Repository Class
public interface ParentRepository extends JpaRepository<Parent, Long> {}
Service Class
@Service
public class ParentService {
private final ParentRepository parentRepository;
@Autowired
public ParentService(ParentRepository parentRepository) {
this.parentRepository = parentRepository;
}
public List<Parent> getAllParents() {
return parentRepository.findAll();
}
}
위 코드에서 getAllParents()
메소드를 호출하면 부모 엔티티를 조회하는 쿼리 하나와 각 부모 엔티티에 대한 자식 엔티티를 조회하는 쿼리가 N번 실행된다.
N+1 문제 해결 방법
1. 페치 조인(Fetch Join)
가장 일반적인 해결 방법 중 하나는 페치 조인을 사용하는 것이다. 페치 조인은 JPQL에서 join fetch
를 사용하여 한 번의 쿼리로 연관된 엔티티들을 함께 조회하는 방법이다.
Repository Class
public interface ParentRepository extends JpaRepository<Parent, Long> {
@Query("SELECT p FROM Parent p JOIN FETCH p.children")
List<Parent> findAllWithChildren();
}
이렇게 하면 findAllWithChildren()
메소드를 호출할 때 부모와 자식 엔티티를 한 번의 쿼리로 가져올 수 있다.
예시 코드
public interface ParentRepository extends JpaRepository<Parent, Long> {
@EntityGraph(attributePaths = {"children"})
List<Parent> findAll();
}
@EntityGraph
를 사용하여 페치 조인을 설정할 수도 있다. 이는 쿼리 메소드와 함께 사용할 수 있으며, JPQL을 직접 작성하지 않고도 연관된 엔티티를 함께 조회할 수 있다.
2. 엔티티 그래프(Entity Graph)
엔티티 그래프는 JPA 2.1부터 도입된 기능으로, 페치 타입을 명시적으로 지정하지 않고도 연관된 엔티티를 함께 로딩할 수 있는 방법이다.
Entity Class
@Entity
@NamedEntityGraph(name = "Parent.children", attributeNodes = @NamedAttributeNode("children"))
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;
// getters and setters
}
Repository Class
public interface ParentRepository extends JpaRepository<Parent, Long> {
@EntityGraph(value = "Parent.children")
List<Parent> findAll();
}
3. Batch Size 설정
Batch Size 설정을 통해 N+1 문제를 해결할 수 있다. 이 방법은 Hibernate의 특정 기능을 사용하여 한 번에 여러 개의 엔티티를 로딩하는 방법이다.
application.yml 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
이렇게 설정하면 Hibernate는 한 번에 1000개의 엔티티를 로딩하여 N+1 문제를 줄일 수 있다.
4. 서브 셀렉트(Subselect)
서브 셀렉트는 부모 엔티티를 로드할 때 한 번의 쿼리로 자식 엔티티를 로드하는 방법이다.
Entity Class
@Entity
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "parent")
@BatchSize(size = 10)
@Fetch(FetchMode.SUBSELECT)
private List<Child> children;
// getters and setters
}
이 방법은 많은 부모 엔티티와 관련된 자식 엔티티를 로딩할 때 유용하다.
Batch Size 설정 시 고려 사항
Batch Size 설정은 매우 유용하지만, 대규모 데이터를 로드할 때 메모리 사용량이 증가할 수 있는 점을 고려해야 한다. 설정된 Batch Size만큼 한꺼번에 데이터를 로드하므로, 너무 큰 값으로 설정하면 메모리 사용량이 급격히 증가할 수 있다. 적절한 Batch Size 값을 설정하여 성능과 메모리 사용 간의 균형을 맞추는 것이 중요하다.
경험담
이전 프로젝트에서 Batch Size 설정을 통해 N+1 문제를 해결한 경험이 있다. 당시 관리자 대시보드 조회가 처음에는 9초에서 11초 정도로 너무 느렸다. 대시보드뿐만 아니라 다른 조회 부분도 상당한 시간이 걸렸다. 이 문제를 해결하기 위해 default_batch_fetch_size 값을 1000으로 설정했다. 설정만으로도 쉽게 N+1 문제를 해결할 수 있었고, 조회 성능이 크게 향상되어 이제는 조회가 1초 만에 이루어지게 되었다. 메모리 이슈에 대해서는 미처 고려하지 않았지만, 이후에 이 설정이 메모리 사용에 영향을 미칠 수 있다는 점을 알게 되었다.
예시 코드
Service Class
@Service
public class ParentService {
private final ParentRepository parentRepository;
@Autowired
public ParentService(ParentRepository parentRepository) {
this.parentRepository = parentRepository;
}
@Transactional(readOnly = true)
public List<Parent> getAllParents() {
return parentRepository.findAll();
}
}
성능 최적화를 위한 권장 사항
- 적절한 Batch Size 설정: Batch Size를 너무 작게 설정하면 N+1 문제를 완전히 해결하지 못하고, 너무 크게 설정하면 메모리 사용량이 증가할 수 있다. 테스트를 통해 적절한 값을 찾는 것이 중요하다.
- 페치 조인과의 조합: 페치 조인과 Batch Size 설정을 함께 사용하여 성능을 최적화할 수 있다.
- 엔티티 그래프 사용: 복잡한 연관 관계가 있을 경우 엔티티 그래프를 사용하여 명시적으로 필요한 데이터를 로드할 수 있다.
각 방법의 이슈
1. 페치 조인(Fetch Join)
페치 조인을 사용할 때의 주의 사항은 다음과 같다:
- 쿼리 복잡성: 여러 연관 관계를 함께 조회하려면 쿼리가 복잡해질 수 있다.
- 페치 조인 제한: Hibernate는 페치 조인의 개수를 제한하는 경우가 있다. 너무 많은 페치 조인을 사용할 경우 예외가 발생할 수 있다.
2. 엔티티 그래프(Entity Graph)
엔티티 그래프를 사용할 때의 주의 사항은 다음과 같다:
- JPA 2.1 이상 필요: 엔티티 그래프는 JPA 2.1부터 지원되므로, 해당 버전 이상의 JPA를 사용해야 한다.
- 복잡한 그래프 관리: 복잡한 엔티티 관계를 모두 그래프로 정의하려면 관리가 어려울 수 있다.
3. Batch Size 설정
Batch Size 설정을 사용할 때의 주의 사항은 다음과 같다:
- 메모리 사용량 증가: Batch Size가 클수록 메모리 사용량이 증가할 수 있다. 적절한 값을 설정하는 것이 중요하다.
- 일괄 처리 성능: Batch Size가 작으면 일괄 처리 성능이 저하될 수 있다.
4. 조회 쿼리 직접 작성
N+1 문제를 해결하기 위해 직접 쿼리를 작성하여 필요한 데이터를 한 번에 조회하는 방법도 있다. 이 방법은 JPQL이나 네이티브 SQL을 사용하여 복잡한 쿼리를 직접 작성하는 것을 의미한다.
Repository Class
public interface ParentRepository extends JpaRepository<Parent, Long> {
@Query("SELECT p FROM Parent p LEFT JOIN FETCH p.children WHERE p.id = :id")
Parent findByIdWithChildren(@Param("id") Long id);
}
마무리
JPA를 사용하면서 발생할 수 있는 N+1 문제는 성능 저하의 주요 원인 중 하나다. 이를 해결하기 위해 다양한 방법들이 있지만, 각 방법은 상황에 따라 적절히 선택하여 사용해야 한다. 개인적으로 프로젝트에서 Batch Size 설정을 통해 N+1 문제를 해결했는데, 이는 설정만으로도 쉽게 해결할 수 있어 매우 유용했다. 다만, Batch Size 설정 시 메모리 사용량이 증가할 수 있다는 점을 유의해야 하는 것을 알았다.
'Framework > Spring' 카테고리의 다른 글
JPA에서 트랜잭션 처리 및 데이터베이스 격리 수준 (0) | 2024.07.19 |
---|---|
Spring Data JPA 이해하기: 주요 구성 요소와 어노테이션 (0) | 2024.07.19 |
JPA 이해하기: 동작 원리와 핵심 구성 요소 (0) | 2024.07.19 |
[MacOS] Spring Boot 애플리케이션에서 SSL 인증서 설정하기: keystore.p12 파일 생성 및 구성 (0) | 2024.04.22 |
Spring의 WebConfig로 해결한 문자 인코딩의 미스터리 (2) | 2024.04.18 |