개요
N+1 문제란 1개의 조회를 위해 N개의 추가적인 쿼리가 발생하는 문제를 말한다. JPA에서 조회 쿼리를 날릴 경우, 해당 엔티티와 연관된 모든 관계에 있는 내용도 받아오기 때문에 발생한다.
왜 N+1 문제가 실무에서 위험한가?
실무에서 N+1 문제는 데이터 로딩 시 의도치 않은 다량의 쿼리가 발생해 서버와 DB를 동시에 압박한다. 목록 1건마다 추가 연관 데이터를 별도 쿼리로 가져오면, N개의 엔티티에 대해 1+N번의 쿼리가 나가며 트래픽이 늘어날수록 지수적으로 지연이 커진다. 이는 TPS 하락, 타임아웃, 커넥션 풀 고갈, 캐시 무력화 같은 장애로 직결된다.
특히 ORM에서 지연 로딩이 컬렉션/연관 엔티티 접근 시마다 쿼리를 발생시킨다. 페이지네이션 환경에서 더 치명적인데, 페이지당 20건이라도 각 건마다 여러 연관을 읽으면 수십~수백 쿼리로 불어난다. 배치 크기나 2차 캐시가 없으면 네트워크 RTT가 병목이 되고, DB는 잦은 인덱스 탐색으로 I/O가 증가한다.
Reproduce
키워드를 통한 Blueprint를 검색하는 JPQL을 사용하고 있다. 이 경우에 당연하게도 Blueprint에는 주문 엔티티(Order)와 연관된 OrderBlueprint와 장바구니 엔티티(Cart)와 연관된 CartBlueprint가 각각 @OneToMany로 연관되어 있다.
[Blueprint.java]
@OneToMany(mappedBy = "blueprint")
private List<OrderBlueprint> orderBlueprints = new ArrayList<>();
@OneToMany(mappedBy = "blueprint")
private List<CartBlueprint> cartBlueprints = new ArrayList<>();
[BlueprintRepository]
@Query(value = """
SELECT b
FROM Blueprint b
WHERE (b.blueprintName LIKE %:keyword% OR b.creatorName LIKE %:keyword%)
AND b.inspectionStatus = :status
""")
Page<Blueprint> findAllNameAndCreatorContaining(@Param("keyword") String keyword,
@Param("status") InspectionStatus status,
Pageable pageable);
현재 모든 연관관계가 지연 로딩이 설정되어 있다. 조회된 Blueprint의 주문수를 표기해주기 위해 orderBlueprints를 사용해야 한다. 이 경우 orderBlueprints의 각 엔티티에 대한 정보가 조회된다. 이 경우, Blueprint의 orderBlueprints와 cartBlueprints의 ID마다 조회하는 쿼리가 실행하므로 N+1이 발생한다.
테스트 코드
@DisplayName("키워드 검색 시, N+1 문제가 발생한다.")
@Test
@Transactional
void keyword_n_plus_1_test() {
// given
Pageable pageable = PageRequest.of(0, 20);
Session session = entityManager.unwrap(Session.class);
Statistics statistics = session.getSessionFactory().getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
// when
Page<Blueprint> blueprints = blueprintRepository.findAllNameAndCreatorContaining("마을", InspectionStatus.PASSED, pageable);
// 지연 로딩된 연관 엔티티에 접근하여 N+1 쿼리 유발
for (Blueprint blueprint : blueprints.getContent()) {
blueprint.getCartBlueprints().size();
blueprint.getOrderBlueprints().size();
}
// then
long queryCount = statistics.getPrepareStatementCount();
System.out.println("Executed queries: " + queryCount);
assertThat(queryCount).as("N+1 문제가 발생했습니다. 예상 쿼리 수: 1, 실제 쿼리 수: " + queryCount).isEqualTo(1L);
}

대표적인 해결 전략
1. Fetch Join
2. @EntityGraph
3. BatshSize / 글로벌, 컬렉션별 설정
1. Fetch Join
JPQL에서 join fetch를 사용하면 지연로딩 설정과 관계없이 해당 연관 엔티티를 즉시 로딩한다. 실제 SQL에서 내부적으로는 조인된 테이블들을 한 SQL로 조회합니다. 결과는 조인으로 인해 중복 행이 생길 수 있지만 JPA가 영속성 컨텍스트에서 엔티티를 식별키로 합쳐준다.
이 방법을 사용할 경우, 주의 사항이 있다.
- 컬렉션 페치 조인 1개 제한: 컬렉션 연관에 대해 하나만 사용가능하다. 여러 컬렉션을 fetch join하면 데이터 폭증과 중복으로 페이징이 불가능해집니다.
- 페이징 불가 이슈: 컬렉션 fetch join을 사용하면 JPA가 메모리에서 페이징을 시도하거나 DB 페이징이 깨질 수 있다. 필요하면 batch-size나 별도 쿼리 분리로 대응하세요.
- Distinct 사용: 중복 엔티티 제거를 위해 JPQL distinct를 함께 쓰는 것이 일반적이다.
- 카디널리티 고려: ToOne(다대일/일대일)은 fetch join이 안전하고 유용합니다. ToMany(일대다/다대다)은 결과 폭증, 페이징 문제를 초래할 수 있다.
왜 페이징이 안 되는가
컬렉션을 fetch join하면 부모 엔티티 한 건이 자식 수만큼 SQL 결과 행으로 늘어납니다. DB의 limit/offset은 조인 후의 “행”에 적용되므로, 원하는 “부모 기준 페이지”가 아니라 “조인 결과 일부”만 잘려 정확한 페이지가 되지 않습니다. 또한, Hibernate는 조회된 중복 행을 영속성 컨텍스트에서 부모 키로 합쳐 엔티티를 중복 제거한다. 그러나 이는 DB 페이징이 끝난 후에 결과에 대해 일어나므로, 페이지 경계가 이미 왜곡된 상태이다. JPQL의 distinct도 DB에선 행 중복을 줄여도, 컬렉션 조인 특성상 완전한 부모 단위 페이징을 보장하지 못한다. 그런 이유로 Hibernate는 컬렉션 fetch join이 포함된 경우 DB 페이징을 안전하게 적용할 수 없어 메모리에서 페이징을 시도하거나 경고/제약을 둡니다. 대량 데이터에서 성능, 메모리 문제가 발생한다.
가장 간단한 해결 방법인 Fetch Join을 적용했다.
@Query(value = """
SELECT b
FROM Blueprint b JOIN FETCH b.orderBlueprints
WHERE (b.blueprintName LIKE %:keyword% OR b.creatorName LIKE %:keyword%)
AND b.inspectionStatus = :status
""")
Page<Blueprint> findAllNameAndCreatorContaining(@Param("keyword") String keyword,
@Param("status") InspectionStatus status,
Pageable pageable);

그러나 FETCH JOIN 사용 시 페이징 사용을 권장하지 않기 때문에 다른 방법을 사용해야 한다. 이를 부모 먼저 페이징, 자식 나중 로딩 이라고 이름을 붙이겠다. 먼저 부모 엔티티만 페이징해 부모 ID 목록을 얻고, 두 번째 쿼리로 자식 컬렉션을 로딩한다. 즉, “부모 페이지 쿼리 + IN(parent_ids)로 자식 로딩을 로딩하는 방식이다.
현재 Blueprint는 OrderBlueprint를 모두 패치해온다. 이를 FETCH JOIN으로 한 번에 로드해버릴 경우, 중복이 발생하기 때문에 페이지네이션이 불가능하다. 여기서 Blueprint를 먼저 페이징해온 후, 패치해온 Blueprint ID를 가지고 OrderBlueprint를 한번더 조회하는 방식을 사용하면 해결 가능하다. 이를 부르기 쉽게 2단계 분리 전략이라고 부르겠다.
@Query(value = """
SELECT b
FROM Blueprint b
WHERE (b.blueprintName LIKE %:keyword% OR b.creatorName LIKE %:keyword%)
AND b.inspectionStatus = :status
""")
Page<Blueprint> findAllNameAndCreatorContaining(@Param("keyword") String keyword,
@Param("status") InspectionStatus status,
Pageable pageable);
@Query(value = """
SELECT DISTINCT b
FROM Blueprint b
LEFT JOIN FETCH b.orderBlueprints
WHERE b IN :blueprints
""")
List<Blueprint> findWithOrderBlueprints(@Param("blueprints") List<Blueprint> blueprints);

쿼리 상에서 FETCH JOIN 살펴보기
SELECT DISTINCT *
FROM BLUEPRINT B
INNER JOIN ORDER_BLUEPRINT OB
ON B.ID = OB.BLUEPRINT_ID
AND (OB.IS_DELETED = 0)
WHERE
(B.IS_DELETED = 0)
AND B.ID IN (?, ?, ?);
실제 쿼리에서 FETCH JOIN은 INNER JOIN을 사용한다. 그리고 중복된 데이터가 나오기 때문에 JPQL에서 DISTINCT를 사용하여 중복을 제거했다. 만약 중복 제거를 해주지 않을 경우에 왜 중복이 발생하는지 알아보자.
INNER JOIN은 다시말해 교집합을 의미한다. 따라서, 조건에 따라 join되는 모든 값들이 나오게 된다.

예를 들어서 위는 Fetch Join을 이용한 TEAM과 MEMBER의 조인 결과이다. ManUnited라는 팀이 각각 Park Ji Sung, C.Ronaldo와 매치가되어 2번의 중복된 결과가 나오게 되는 것이다.
2. EntityGraph
@EntityGraph는 엔티티에 선언한 attribute nodes를 “이 연관들을 같이 가져와라”라는 힌트로 Hibernate에 전달하는 어노테이션이다. Hibernate는 그래프에 포함된 연관을 즉시 로딩하기 위해 보통 JOIN 또는 추가 SELECT를 조합합니다. 결과적으로 N+1을 줄이되, 선택한 경로만 즉시 로딩합니다.
- 두 모드: fetchgraph vs loadgraph
- fetchgraph: 그래프에 포함된 속성만 즉시 로딩, 그 외에는 모두 지연로딩으로 강제
- loadgraph: 그래프에 포함된 속성은 즉시 로딩하되, 기존 매핑의 fetch 타입 규칙을 그대로 따름
장점
화면/용도별 로딩 경로를 그래프로 정의해 재사용, 쿼리를 깔끔하게 유지가 가능하다. 또한, Fetch Join과 다르게 fetchgraph/loadgraph로 기존 매핑의 EAGER/LAZY를 상황별로 재정의가 가능하다. 마지막으로 Spring Data JPA의 Page 조회에서 카운트 쿼리에는 그래프가 적용되지 않아 불필요한 조인을 피할 수 있다.
주의사항
컬렉션을 그래프에 포함하면 조인 결과가 늘어나 limit/offset이 부모 기준에서 깨짐. 대용량에서는 성능·정확성 문제이 발생할 수 있다. Fetch Join과 마찬가지로 Hibernate는 여러 List를 동시에 즉시 페치하면 “cannot simultaneously fetch multiple bags” 예외가 날 수 있다. 그리고 가장 신경쓰이는 부분이 세밀한 제어를 하기 위해선 JPQL/Querydsl를 사용해야 한다. EntityGraph는 로딩 경로 지정이 주 목적이기 때문에 세밀한 쿼리 조정이 어렵다
적용
@NamedEntityGraph를 이용하면 재사용이 하기 훨씬 편해진다. Blueprint 엔티티에 EntityGraph를 설정해준다. 그리고 등록한 그래프를 실제로 사용할 Repositoy에 선언해주면 적용된다.
[Blueprint.java]
@NamedEntityGraph(
name = "Blueprint.withOrderBlueprints",
attributeNodes = {
@NamedAttributeNode("orderBlueprints")
}
)
@Entity
@Table
(indexes = {
@Index(name = "idx_blueprint_second_category", columnList = "categoryId, secondCategory")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE Blueprint SET is_deleted = true WHERE id = ?")
@Builder
@AllArgsConstructor
public class Blueprint extends BaseEntity {
// [...]
}
[BlueprintRepository.java]
@EntityGraph(value = "Blueprint.withOrderBlueprints", type = EntityGraph.EntityGraphType.LOAD)
@Query(value = """
SELECT b
FROM Blueprint b
WHERE (b.blueprintName LIKE %:keyword% OR b.creatorName LIKE %:keyword%)
AND b.inspectionStatus = :status
""")
Page<Blueprint> findAllNameAndCreatorContaining(@Param("keyword") String keyword,
@Param("status") InspectionStatus status,
Pageable pageable);

쿼리 상에서 @EntityGraph 살펴보기
SELECT *
FROM BLUEPRINT B
LEFT OUTER JOIN ORDER_BLUEPRINT OB
ON B.ID = OB.BLUEPRINT_ID
AND (OB.IS_DELETED = 0)
WHERE
(B.IS_DELETED = 0)
AND B.ID IN (?, ?, ?);
@EntityGraph도 left outer join을 사용하는데 왜 중복 문제가 발생하지 않는 것일까? 사실 EntityGraph 또한 포함한 연관을 즉시 로딩하면 SQL 조인으로 인해 결과 행이 반복될 수 있다. 하지만 Hibernate는 결과를 엔티티로 만들 때 같은 PK를 가진 엔티티가 다시 나타나면 새로 생성하지 않고 기존 인스턴스를 재사용하며, 연관 컬렉션와 필드를 채워 넣는다. 그래서 SQL 레벨의 중복 행이 최종 엔티티 그래프에서는 하나로 합쳐져 보입니다.
Fetch Join의 경우, JPQL을 이용하기 때문에 Hibernate는 SQL 쿼리로 변환하여 실행만 진행한다. 하지만 @EntityGraph의 경우, Hibernate가 이 힌트를 바탕으로 중복 제거를 진행하기 때문에 중복이 발생하지 않는 것이다.
3. batch-size 조정
Hibernate가 연관된 엔티티나 컬렉션을 로딩할 때, 한 번에(Batch) 로딩할 수 있는 최대 개수를 지정하는 설정이다. 원래는 부모 엔티티 하나당 자식 엔티티를 조회하는 쿼리가 하나씩(N번) 나가야 하지만, Batch Size를 설정하면 SQL의 IN 절을 사용하여 여러 부모의 자식들을 한 번의 쿼리로 조회한다.
예를 들어, Team(1)과 Member(N)이 있는 경우 전체 팀을 조회할 경우 아래 처럼 각 팀에 대한 Member 조회 쿼리도 한번에 날라간다. 그래서 1번의 쿼리가 총 10개의 추가 쿼리를 발생시킨다. (1 + 10)
SELECT * FROM team; # 팀 10개 있다고 가정
SELECT * FROM member WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
SELECT * FROM member WHERE team_id = 4;
...
SELECT * FROM member WHERE team_id = 10;
만약 batch-size를 100으로 설정하면, 팀 조회 시 멤버 조회 쿼리가 하나의 쿼리로 날라가게 된다. 그래서 총 2개의 쿼리로 줄일 수 있게 된다.
SELECT * FROM member WHERE team_id in (1,2,3,4,5,6,7,8,9,10);
주의 사항
보통 100~1000 사이를 권장한다. 너무 작게 설정하면 (예: 10), 데이터가 1,000개라면 쿼리가 100번(1000/10) 나간다. N+1보다는 낫지만 여전히 쿼리가 많아 비효율적입니다. 너무 크게 설정하면 (예: 5,000 이상), 한 번에 너무 많은 엔티티를 메모리에 올리게 된다. 만약 엔티티 하나가 아주 무거운 데이터(큰 문자열, 이미지 바이너리 등)를 포함하고 있다면, 서버 메모리가 터질 수 있다. 또한, DB 입장에서 IN 절에 파라미터가 수천 개가 들어오면, 이를 파싱하고 실행 계획을 세우는 데 순간적으로 많은 CPU를 사용된다.
적용
application.properties에 아래 처럼 설정만 추가해주면 손쉽게 설정이 가능하다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100

결론
페이지네이션을 위한 선택
Fetch Join / @EntityGraph 이 둘은 사실상 같은 기술이다. @EntityGraph는 JPQL 없이 Fetch Join을 사용하는 방법에 불과하다. 그러나 이 두가지 방법은 페이지네이션과 충돌한다는 단점이 있다. 그러나 Batch Size의 경우 지연 로딩은 그대로 지만, 하지만 첫 번째 Blueprint의 cartBlueprints에 접근하는 순간, Hibernate는 "어차피 다른 Blueprint들의 cartBlueprints도 곧 필요하겠지?"라고 예측하고, 지정된 batch_size만큼의 Blueprint ID를 모아 IN 절 쿼리를 날려줍니다. 따라서, 페이지네이션과 충돌이 발생하지 않는다.
따라서, 페이지네이션이 중요한 이번 프로젝트에선 batch-size를 통해 N+1 문제를 해결하는 방향으로 진행했다.
적용 결과
batch-size 적용 후
# 1. SELECT 쿼리
SELECT
b.id,
b.blueprint_details,
b.blueprint_img,
b.blueprint_name,
b.updated_at
FROM
blueprint b
WHERE
(
b.blueprint_name LIKE ? ESCAPE ''
OR b.creator_name LIKE ? ESCAPE ''
)
AND b.inspection_status = ?
OFFSET ? ROWS
FETCH FIRST ? ROWS ONLY;
2. 페이지네이션을 위한 COUNT 쿼리
SELECT
count(b.id)
FROM
blueprint b
WHERE
(
b.blueprint_name LIKE ? ESCAPE ''
OR b.creator_name LIKE ? ESCAPE ''
)
AND b.inspection_status = ?;
3. OrderBlueprint 연관 데이터 조회
SELECT
ob.blueprint_id,
ob.id,
ob.created_at,
ob.download_url,
ob.is_deleted,
ob.orders_id,
ob.updated_at
FROM
order_blueprint ob
WHERE
ob.blueprint_id IN (?, ?, ?, ?, ... 100개 ...)
AND (
ob.is_deleted = false
);
22개의 총 3개의 쿼리로 줄일 수 있었다. 추후 데이터가 많아질 경우, 배치 사이즈로 인해 문제가 발생할 수 있다고 생각된다. 만약 배치 사이즈로 이상이 생길 경우, batch-size를 재조정할 수 있지만, 지연 로딩 및 쿼리 분리를 통해 필요한 정보만 가져오도록 할 예정이다. 현재는 이 정도로 만족하고, 넘어가자.
'Spring' 카테고리의 다른 글
| [Spring Boot] AI 서빙에서 WebClient와 RestClient 비교 (0) | 2025.11.30 |
|---|---|
| [Spring Boot] Scale Out을 위한 Redis Pub/Sub 기반 공개 채팅 아키텍처 도입 (0) | 2025.11.30 |
| [Spring Boot] Soft Delete로 개인정보 보호하기 (0) | 2025.11.30 |
| [Spring Boot] 공개 채팅 구축 및 동시성 문제 해결 (0) | 2025.11.27 |
| [Test] 이럴 거면 테스트 코드를 왜 작성하는 거야? (0) | 2025.11.27 |