
들어가며
최근 IT 창업 동아리 멋쟁이 사자처럼 홈페이지를 만들며 조회 성능 개선을 진행하였다. 그 과정에서 N+1 문제를 해결하기 위해 JPQL을 도입하였고 그동안 추상적으로만 알고 있던 N+1 문제에 대해서 정리하려고 한다.
처음에는 N+1문제가 발생하는 조건과 상황을 소개하고, 이후에 3가지 해결책을 소개하며, 마지막에는 상황에 맞는 전략을 정리할 것이다.
특히 마지막 부분에는 결론적으로 N+1문제가 발생할 수 밖에 없던던 Spring Data JPA의 설계 철학과 의도에 대해서 본인만의 생각을 정리하고자 한다.
N+1 문제 상황
N+1 문제란?
JPA를 사용하면서 지연 로딩으로 설정된 엔티티의 연관관계를 조회할 때, 예상보다 많은 SQL 쿼리가 실행되어 불필요한 DB 조회가 발생하는 현상을 말한다.
문제 발생 조건

멋사 회원 테이블을 Member, 포트폴리오 링크 테이블을 PortfolioLink라고 해보자. 하나의 회원은 GitHub, 블로그, 포트폴리오 등 여러 개의 링크를 가질 수 있다.
즉, Member는 여러 개의 PortfolioLink를 가질 수 있으며, 이를 테이블 관계로 표현하면 1:N 관계가 만들어진다.
먼저 첫 번째 조건은 연관관계가 JPA의 지연 로딩으로 설정되어 있는 경우다.
@Entity
public class Member {
@OneToMany(mappedBy = "member")
private List<PortfolioLink> portfolioLinks;
}
일반적으로 JPA에서 @OneToMany의 기본 FetchType은 LAZY다. 따라서 연관관계가 지연 로딩 전략으로 설정되어 있을 경우, Member를 조회하는 시점에는 portfolioLinks가 실제 데이터로 바로 조회되지 않는다.
두 번째 조건은 Member를 조회한 이후 실제로 연관관계까지 조회하는 경우다.
회원 목록만 조회하고 회원과 연관된 포트폴리오 링크를 사용하지 않는다면 추가 쿼리는 발생하지 않는다.
하지만 일반적으로 비즈니스 로직에서 회원 목록을 조회한 뒤, 각 회원의 포트폴리오 링크까지 보여줘야 하는 상황에서는 추가 쿼리가 발생한다.
// 1. 회원 목록 조회
List<Member> members = memberRepository.findAll();
// 2. 이후 각 회원의 포트폴리오 링크 조회
for (Member member : members) {
member.getPortfolioLinks().size();
}
즉, 처음에는 portfolioLinks의 실제 데이터가 조회되지 않은 상태이고, 이후 실제 portfolioLinks 데이터가 필요한 시점에 DB로 추가 SELECT 쿼리가 발생한다.
이 과정에서 N+1 문제가 발생한다.
N+1 문제 발생 흐름

먼저 회원 목록을 조회하면서 최초 쿼리가 한 번 실행된다.
SELECT *
FROM member;
이후 각 회원의 포트폴리오 링크를 조회하는 과정에서 회원 수만큼 추가적인 N번의 쿼리가 발생한다.
/** 멤버 수만큼 N번 추가 쿼리 발생 **/
SELECT *
FROM portfolio_link
WHERE member_id = 1;
SELECT *
FROM portfolio_link
WHERE member_id = 2;
...
결과적으로 실행되는 쿼리 수는 1 + N번이다.
다만 관례적으로 “N개의 대상마다 추가 쿼리가 발생하고, 여기에 최초 조회 1번이 더 붙는다”고 표현하기 때문에 이를 N+1 문제라고 부른다.
해결 방안
처음에는 세 가지 방법 모두 단순히 “N+1 문제를 없애주는 기능” 정도로 생각했다.
하지만 각각의 동작 방식을 살펴보면 N+1 문제를 해결하는 접근 방식에는 조금씩 차이가 있다.
1. JPQL

“어차피 이번 API에서는 PortfolioLink가 필요한데, 처음부터 같이 가져오면 되지 않을까?”
즉, JOIN 쿼리를 이용해 연관관계 테이블을 미리 가져온다.
처음 조회할 때 필요한 연관 데이터까지 함께 가져오면 이후 추가 쿼리가 발생할 조건 자체를 차단할 수 있다. 따라서 최초 쿼리 한 번으로 필요한 정보를 모두 가져올 수 있다.
보통 JPQL의 Fetch Join을 사용하여 직접 JOIN 쿼리가 발생하도록 설정한다.
@Query("""
select m
from Member m
join fetch m.portfolioLinks
""")
List<Member> findAllWithPortfolioLinks();
이 방식은 특정 API에서 어떤 연관 데이터가 반드시 필요하다는 사실이 명확한 경우 사용하기 좋다.
다만 OneToMany 컬렉션을 Fetch Join하면서 페이징까지 함께 사용해야 하는 경우에는 별도의 문제가 발생할 수 있다.
따라서 모든 상황에서 무조건 Fetch Join으로 해결할 수 있는 것은 아니다.
2. EntityGraph

EntityGraph는 한 번에 조회하고 싶은 연관관계를 별도로 선언하는 방식이다.
즉, 조회 로직과 Fetch 전략을 코드에서 분리하여 표현한다.
@Repository
public interface MemberRepository
extends JpaRepository<Member, Long> {
@Override
@EntityGraph(attributePaths = "portfolioLinks")
List<Member> findAll();
}
엔티티 자체는 여전히 LAZY를 유지한다.
하지만 Repository의 findAll() 메서드가 실행되는 경우에는 portfolioLinks 연관관계도 함께 로딩하도록 Fetch Plan을 지정한다.
Fetch Join을 사용하기 위해 매번 긴 JPQL을 직접 작성하지 않아도 된다는 장점이 있다.
3. Batch Size

앞의 두 방식이 필요한 연관관계를 미리 조회하여 추가 쿼리 발생을 막는 방식이었다면, Batch Size는 추가적으로 발생하는 N번의 쿼리를 N / batch_size 수준으로 줄이는 방식이다.
여러 개의 조회를 SQL의 IN 문법을 이용해 하나의 쿼리로 묶어서 처리한다.
SELECT *
FROM portfolio_link
WHERE member_id IN (1, 2, 3, ..., 50);
SELECT *
FROM portfolio_link
WHERE member_id IN (51, 52, ..., 100);
Spring Boot에서는 보통 YAML을 통해 전역 Batch Size를 설정할 수 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
따라서 Batch Size를 K라고 하면 총 쿼리 수는 대략 1 + ceil(N / K)번이 된다.
언제 무슨 전략을 쓸까?
사실 개인적으로는 이 부분이 가장 중요하다고 생각한다.
앞에서 각각의 해결 방법을 살펴본 이유도 결국 어떤 상황에서 어떤 전략을 선택할 것인가를 판단하기 위해서다.
Fetch JOIN vs EntityGraph

Fetch Join과 EntityGraph는 결국 동일한 기능을 하는 것은 아닐까?
JPQL이 “무엇을 조회하는가”에 집중한다면, EntityGraph는 “무엇을 함께 조회하는가”에 집중한다.
먼저 Fetch Join은 JPQL 안에서 조회 조건과 함께 가져올 연관관계를 직접 명시한다.
@Query("""
select m
from Member m
left join fetch m.portfolioLinks
where m.id = :id
""")
Optional<Member> findByIdWithPortfolioLinks(Long id);
해당 쿼리를 보면 Member를 조회하면서 PortfolioLinks까지 함께 가져온다는 의도를 명확하게 확인할 수 있다.
반면 다음과 같이 조회 조건 자체가 단순한 경우를 생각해보자.
findById(...)
findByEmail(...)
findByUsername(...)
특정 API에서 연관 데이터까지 함께 사용해야 한다고 해서 매번 Fetch Join을 위한 JPQL을 직접 작성하는 것은 번거로울 수 있다.
이럴 때 EntityGraph를 사용해 Fetch Plan만 추가로 선언할 수 있다.
즉, “이번 findById()에서는 portfolioLinks까지 함께 로딩한다”는 계획을 별도로 작성하는 것이다.
@EntityGraph(attributePaths = "portfolioLinks")
Optional<Member> findById(Long id);
여러 연관관계가 필요하다면 다음과 같이 지정할 수도 있다.
@EntityGraph(attributePaths = {
"portfolioLinks",
"lionInfos"
})
Optional<Member> findById(Long id);
@Entity
class Member {
@OneToMany(mappedBy = "member")
private List<PortfolioLink> portfolioLinks;
}
연관관계 컬렉션과 페이징을 함께 해야 하는 상황
JPA 컬렉션과 페이징을 함께 사용하는 상황에서는 객체 관점과 DB 관점이 충돌한다. 따라서 페이징 대상을 먼저 조회한 뒤 Batch Size를 조절하여 선택적으로 추가 쿼리를 발생시킨다.
OneToMany와 같은 컬렉션을 Fetch Join하면 DB의 결과 행(row)이 부모 엔티티 기준이 아니라 JOIN 결과 기준으로 증가한다.
이 때문에 LIMIT/OFFSET을 이용한 페이징과 충돌할 수 있다.
Member
────────────────
1. 철수 → Github, Blog, Portfolio 3개
2. 영희 → Github 1개
3. 민수 → Github, Blog 2개
4. 조던 → Github 1개
회원 목록을 2명씩 페이징해야 한다는 요구사항이 있다고 해보자.
단순히 Member만을 기준으로 페이징하면 다음과 같은 쿼리가 실행되며 아무런 문제가 없다.
SELECT *
FROM member
LIMIT 2 OFFSET 0;
문제 상황
문제는 여기서 PortfolioLink까지 Fetch Join하는 경우 발생한다.
페이징을 적용하기 전에 PortfolioLinks까지 JOIN되어 있다면 어떻게 될까?
정상적인 상황이라면 첫 번째 페이지에는 철수, 영희 두 객체가 존재해야 한다.
하지만 Fetch Join 이후의 페이징 처리 흐름에서는 중복된 Member 데이터가 발생하게 된다.
@Query("""
select m
from Member m
left join fetch m.portfolioLinks
""")
Page<Member> findAllWithPortfolioLinks(Pageable pageable);
SELECT *
FROM member m
LEFT JOIN portfolio_link p
ON m.id = p.member_id;
위와 같은 JOIN이 발생한 결과는 다음과 같다.
Member PortfolioLink
────────────────────────────────
철수 Github
철수 Blog
철수 Portfolio
영희 Github
민수 Github
민수 Blog
조던 Github
기존에는 Member가 4명이었지만 JOIN 결과는 총 7개의 row로 증가했다.
여기에 페이징을 적용하면서 LIMIT 2를 사용하면, DB는 Member 객체가 아니라 JOIN 결과의 row를 기준으로 판단한다.
따라서 첫 번째 페이지에는 중복된 철수 데이터만 포함되는 상황이 발생할 수 있다.
철수 - Github
철수 - Blog
즉, 컬렉션 Fetch Join과 DB 페이징의 근본적인 기준 충돌이 발생한다.
우리가 원하는 것은 Member 객체를 기준으로 한 페이징이지만, DB는 JOIN 결과 row를 기준으로 페이징을 수행하기 때문이다.
SQL의 DISTINCT를 사용하더라도 다음 세 데이터는 PortfolioLink 컬럼 값이 서로 다르기 때문에 근본적인 문제를 해결할 수 없다.
철수 Github
철수 Blog
철수 Portfolio
JPA가 최종적으로 동일한 Member 엔티티를 중복 제거할 수는 있다.
하지만 “LIMIT/OFFSET을 어느 시점에 정확하게 적용할 것인가?”라는 문제는 여전히 남는다.
또한 데이터가 많아질 경우 가져온 데이터를 메모리에서 중복 제거하게 되므로 성능 저하로 이어질 수 있다.
반면 당연하게도 ?ToOne 관계는 Fetch Join을 수행하더라도 이러한 형태의 중복 데이터가 발생하지 않는다.
Batch Size로 어떻게 해결할까?
Batch Size를 이용하면 Member와 PortfolioLinks 조회를 분리할 수 있다.
먼저 Member만 정확하게 페이징한다.
Page<Member> members =
memberRepository.findAll(
PageRequest.of(0, 20)
);
SELECT *
FROM member
ORDER BY id
LIMIT 20 OFFSET 0;
Member 1
Member 2
Member 3
...
Member 20
그다음 PortfolioLinks가 필요해지는 시점에 Hibernate가 현재 영속성 컨텍스트에 존재하는 20명의 Member에 대한 연관 데이터를 함께 조회한다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
위와 같이 YAML에 default_batch_fetch_size를 미리 설정해두면, 해당 설정을 기준으로 다음과 같은 추가 쿼리가 발생한다.
Page<Member> page =
memberRepository.findAll(pageable);
SELECT *
FROM portfolio_link
WHERE member_id IN (
1, 2, 3, 4, 5,
...
20
);
이후 다음 페이지의 Member 20명이 조회되고 DTO 변환 과정에서 PortfolioLinks가 필요해지면, 다시 해당 Member들을 대상으로 IN 쿼리가 발생한다.
page.getContent()
.stream()
.map(MemberResponse::from)
SELECT *
FROM portfolio_link
WHERE member_id IN (
21, 22, 23, 24, 25,
...
40
);
Batch Size를 사용하는 대신 직접 JPQL을 작성하여 IN 쿼리를 발생시키는 방법도 있다.'
주의할 점
Fetch Join이든, EntityGraph든, Batch Size든 결국 중요한 것은 각 전략을 상황에 맞게 사용할 수 있어야 한다는 점이다.
Fetch Join은 JOIN이 코드에 직접 드러난다.
따라서 개발자가 코드를 확인하면서 “JOIN이 너무 많아지고 있는 것은 아닌가?”라고 인식하기 상대적으로 쉽다.
반면 쿼리가 복잡해질수록 코드가 길어지고 관리하기 어려워질 수 있다는 단점이 있다.
EntityGraph는 조회 로직과 Fetch 전략을 분리하여 작성할 수 있어 코드의 가독성을 높일 수 있다.
하지만 반대로 문법이 간단하기 때문에 실제로 내부에서 어떤 Fetch Join이 발생하는지가 코드에 직접 드러나지 않는다. 따라서 여러 연관관계를 추가하다 보면 카테시안 증폭 문제를 미처 발견하지 못할 수도 있다.
Batch Size 역시 상황에 맞게 적용해야 한다.
결국 JOIN이 발생할 때는 테이블 관점에서 발생하는 카테시안 곱으로 인한 성능 문제와 데이터 중복 문제를 항상 고려하면서 SQL을 다뤄야 한다.
정리하면 OneToMany 또는 ManyToMany 연관관계 컬렉션을 Fetch Join하면 중복된 JOIN row가 발생할 수 있기 때문에 페이징의 LIMIT과 논리적으로 충돌한다.
따라서 먼저 LIMIT을 사용해 Member를 조회한 뒤, Batch Size만큼 PortfolioLinks를 추가 조회하는 방식으로 문제를 해결할 수 있다.
즉, 객체 관점의 JPA 컬렉션과 테이블 관점의 JOIN, LIMIT가 만나 서로 다른 기준이 충돌하는 문제라고 볼 수 있다.
정리
왜 지연로딩을 Default로 설계했을까?
그럼 여기서 이런 질문이 생긴다.
JPA를 만들 때 모든 연관관계 테이블을 이미 가져오도록 설계할 수는 없었을까?
Spring Data JPA에서는 기본적인 연관관계를 LAZY로 설계함으로써 N+1문제가 발생할 수 밖에 없도록 설계했다. 상황별로 여러 해결채을 적용해보며 내린 결론은 "결국 어떤 해결 방식이던 정답은 없다"는 것이다. 단지 상황에 조금 더 유리한 전략이 존재할 뿐이다.
따라서 JPA를 만든 설계자들도 의도적으로 체크 포인트를 남겨 이후 개발자가 실제 조회 상황에 맞는 전략을 선택할 수 있도록 자율성을 남겨둔 것이다. 이게 JPA와 N+1 문제를 공부하며 내린 결론이다.
Fetch Join이 만능일까?
JPA는 엔티티의 기본적인 연관관계를 객체 관점에서 다루는 데 매우 편리하다.
하지만 조회 로직이 복잡해지면 성능 저하나 데이터 중복과 같은 문제가 발생할 수 있다.
테이블을 JPA를 통해 객체 관점으로 다루기 때문에 발생하는 문제이며, 결국 핵심 조회 로직에서는 개발자가 직접 SQL을 분석하고 JOIN 결과에 중복 데이터가 발생하는지 고려하면서 설계해야 한다.
결국 JPA가 많은 부분을 대신 처리해주더라도 개발자가 직접 쿼리를 이해하고 다룰 수 있어야 한다.
상황에 따라 JPA에 맡겨도 되는 부분은 맡기고, DB 성능을 직접 제어해야 하는 부분에서는 쿼리를 명시적으로 통제하는 것이 중요하다.