JPA의 N+1 문제는 쿼리 폭증으로 DB 부하를 일으키고, 결국 사용자 경험과 시스템 안정성을 해칠 수 있다.
이 글은 N+1 문제의 원인과 실무 해결법을 코드 예시와 함께 정리해볼려고 한다.
✅ N+1 문제란?
- N+1 문제는 JPA 같은 ORM에서 연관 관계를 조회할 때 불필요한 쿼리가 반복 발생하는 현상이다. 예를 들어, 하나의 엔티티를 조회한 뒤 연관된 데이터를 조회하려고 하면, 연관 데이터 개수만큼 추가 쿼리가 날아간다.
왜 생기나?
- 객체지향 언어는 메모리 내에서 참조로 쉽게 데이터를 탐색하지만, 관계형 DB는 쿼리로 데이터를 가져와야 한다.
- 특히 @OneToMany 관계에서 FetchType.LAZY 설정 시, 프록시 객체가 생성되고 이 객체를 조회할 때마다 추가 쿼리가 발생한다.
✅ N+1 문제 예시
- n+1 문제를 고의로 발생시키기 위해 아래와 같은 코드를 작성해보았다.
- 국가(Country) 와 도시(City)는 1:n 양방향 관계이다.(실제로는 단방향+단방향)
♐ CountryEntity
@Entity
@Getter
@Setter
public class CountryEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String country;
@OneToMany(mappedBy = "countryEntity")
private List<CityEntity> cityEntities = new ArrayList<>();
}
♐ CityEntity
@Entity
@Getter
@Setter
public class CityEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String city;
@ManyToOne
private CountryEntity countryEntity;
}
CountryService에서 모든 국가를 조회하는 메서드를 호출한다고 해보자
♐ CountryService
public List<CountryEntity> readCountry() {
return countryRepository.findAll();
}
그리고 컨트롤러에서 이 데이터를 뷰로 넘긴다
♐ ReadController
@GetMapping("/read/one/child")
public String readOneChild(Model model) {
model.addAttribute("COUNTRYLIST", countryService.readCountry());
return "readOneChild";
}
데이터를 삽입하고 Country 의 city 목록을 가져온다.
데이터를 삽입하고 Country 의 city 목록을 가져온다.
- CountryEntity 목록을 조회하는 1개의 쿼리.
- 각 CountryEntity의 cityEntities를 조회하는 3개의 추가 쿼리.
총 1+3=4개의 쿼리가 발생했다. 국가가 100개라면? 101개 쿼리가 날아간다.
✅ N+1 문제 해결방법
- N+1 문제를 해결하려면 쿼리 수를 줄이고 데이터를 효율적으로 가져와야 한다.
1️⃣ Fetch Join
- CountryRepository에 Fetch Join 추가하였다.
♐ CountryRepository
@Query("SELECT DISTINCT c FROM CountryEntity c JOIN FETCH c.cityEntities")
List<CountryEntity> findAllWithCities();
이렇게 적용하니 join 을 통해 쿼리 하나로 처리됐다.
select distinct ce1_0.id,ce2_0.countryEntity_id,ce2_0.id,ce2_0.city,ce1_0.country
from CountryEntity ce1_0
join CityEntity ce2_0
on ce1_0.id=ce2_0.countryEntity_id
2️⃣ BatchSize
- default_batch_fetch_size는 Lazy Loading 시 IN 절로 연관 데이터를 묶음 단위로 조회한다.
- Fetch Join의 페이징 제한을 해결할 수 있다.
application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
해당 batchSize를 설정해주면 , CountryEntity 3개를 조회한 뒤, cityEntities를 IN 절로 한 번에 조회한다.
1 (CountryEntity) + 1 (CityEntity) = 2개.
Fetch Join보다 쿼리가 1개 더 발생하지만, 데이터 전송량은 중복 없이 효율적이다.
그치만 단점도 존재한다.
💡 BatchSize의 단점
- 임의의 값 설정 문제: BatchSize는 개발자가 경험적으로 설정한다. (한마디로 임시방편 느낌)너무 작으면 쿼리 수가 늘어나고, 너무 크면 메모리 부담이 커진다. 예를 들어, size=10으로 설정하면 3개 국가를 10개씩 처리할 필요는 없지만, 국가가 1000개라면 여러 IN 절 쿼리가 발생해 비효율적이다.
- DB 부하 불균형: IN 절로 큰 데이터셋을 조회하면 DB 인덱스 활용이 비효율적일 수 있다. 특히 city_entity 테이블이 크면 쿼리 성능이 떨어진다.
- DBMS 제약: MySQL 등은 IN 절에 포함할 수 있는 값에 제한(보통 1000개)이 있다. BatchSize가 이를 초과하면 쿼리가 실패하거나 여러 쿼리로 나뉜다.
3️⃣ @EntityGraph
- @EntityGraph는 Lazy Loading을 동적으로 Eager Loading으로 전환해 연관 데이터를 한 번에 조회한다.
♐ CountryRepository
public interface CountryRepository extends JpaRepository<CountryEntity, Long> {
@EntityGraph(attributePaths = {"cityEntities"})
List<CountryEntity> findAll();
}
Fetch Join과 유사한 단일 쿼리로 CountryEntity와 CityEntity 조회하고 여러 @OneToMany 관계를 동시에 처리할 수 있어 유연하다.
✅ 정리
- N+1 문제: 연관 관계 조회 시 쿼리가 불필요하게 많아진다.
- 문제 상황: france, korea, usa 조회 시 1+3=4개 쿼리 발생.
- 해결법:
- Fetch Join: 단일 쿼리로 해결, 페이징은 조심.(자주 사용)
- BatchSize: IN 절로 쿼리 수 감소, 페이징 가능.
- @EntityGraph: 간편하고 유연한 연관 데이터 조회.
'Spring' 카테고리의 다른 글
[Spring Security] 스프링 시큐리티와 JWT로 인증 구현하기 (2) | 2025.05.15 |
---|---|
[Spring] 트랜잭션 내부 호출 문제 (1) | 2025.05.01 |
[Spring]Builder 패턴 vs 생성자, 무엇이 더 나을까? (3) | 2025.04.28 |
[Spring]오프셋 페이징보다 커서 페이징을 써야하는 이유 (1) | 2025.04.25 |
[Spring/JPQL] JPQL이 무엇인지 알고 사용하자 (1) | 2025.04.18 |