본문 바로가기

Spring&IntelliJ

n+1문제에 대해서

 

N+1 문제는 JPA에서 연관관계 데이터를 조회할 때 SQL이 예상보다 많이 실행되는 문제입니다. 특히 FetchType.EAGER(즉시 로딩)에서 자주 발생합니다.

쉽게 말하면:

한 번 조회하면 될 것을, N개의 데이터를 추가로 조회해서 총 N+1번의 SQL이 실행되는 문제

입니다.


1. 예시로 이해하기

다음과 같은 엔티티가 있다고 가정해 보겠습니다.

Member 엔티티

 
@Entity
public class Member {

    @Id
    private Long id;

    private String name;


    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;

}
 

Team 엔티티

 
@Entity
public class Team {

    @Id
    private Long id;

    private String name;

}
 

관계:

Member  N  :  1 Team

회원 여러 명 → 하나의 팀
 

2. Member 10명을 조회한다면?

코드:

 
List<Member> members =
    em.createQuery(
        "select m from Member m",
        Member.class
    )
    .getResultList();
 

개발자는 이렇게 생각합니다.

회원 10명을 가져오는 SQL 하나가 실행되겠지?

실제로 실행되는 SQL:

 
SELECT *
FROM MEMBER;
 

여기까지는 1번입니다.

그런데 Member의 Team이 EAGER입니다.

즉 JPA는:

Member 객체를 만들 때 Team도 반드시 같이 가져와야 한다.

라고 생각합니다.


3. 그러면 추가 SQL이 발생합니다.

예를 들어 결과가:

MemberTeam
김철수 A팀
이영희 A팀
박민수 B팀

이라면 JPA는:

첫 번째 회원:

 
SELECT *
FROM TEAM
WHERE TEAM_ID = 1;
 

두 번째 회원:

 
SELECT *
FROM TEAM
WHERE TEAM_ID = 1;
 

세 번째 회원:

 
SELECT *
FROM TEAM
WHERE TEAM_ID = 2;
 

...

이런 식으로 실행할 수 있습니다.

결과:

회원 조회 SQL 1개
+
팀 조회 SQL N개

= N + 1 문제
 

4. 실제 로그로 보면

처음:

 
select
    member0_.id,
    member0_.name,
    member0_.team_id
from
    member member0_;
 

1번 실행

그 후:

 
select
    team0_.id,
    team0_.name
from
    team team0_
where
    team0_.id=1;
 
 
select
    team0_.id,
    team0_.name
from
    team team0_
where
    team0_.id=2;
 

...

여러 번 실행됩니다.


5. 그런데 왜 JPQL에서 특히 발생할까?

이 부분이 중요합니다.

예를 들어:

 
em.createQuery(
    "select m from Member m",
    Member.class
)
.getResultList();
 

JPQL은 그대로 SQL로 변환됩니다.

즉:

 
SELECT *
FROM MEMBER
 

만 만들어집니다.

JPA는 처음부터:

 
SELECT *
FROM MEMBER
JOIN TEAM
 

으로 만들지 않습니다.

왜냐하면 JPQL은 개발자가 작성한 쿼리이기 때문입니다.


그런데 조회된 Member를 보니:

 
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
 

이 있죠.

그래서 JPA가 뒤늦게:

"아 Team도 필요하네?"

라고 판단하고 추가 조회합니다.


6. 해결 방법: LAZY 사용

그래서 실무에서는 거의 기본적으로:

 
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
 

을 사용합니다.

의미:

Member를 조회할 때 Team은 가져오지 말고, 실제 사용할 때 가져와라.


예:

 
Member member = em.find(Member.class, 1L);
 

실행:

 
SELECT *
FROM MEMBER
WHERE ID=1;
 

끝.


그런데:

 
member.getTeam().getName();
 

을 호출하면 그때:

 
SELECT *
FROM TEAM
WHERE ID=1;
 

실행됩니다.


7. 그러면 LAZY에서는 N+1이 안 생기나?

아닙니다. LAZY에서도 발생합니다.

예:

 
List<Member> members =
    em.createQuery(
        "select m from Member m",
        Member.class
    ).getResultList();


for(Member member : members){
    System.out.println(member.getTeam().getName());
}
 

결과:

처음:

 
SELECT *
FROM MEMBER;
 

그리고 반복문:

 
SELECT *
FROM TEAM WHERE ID=1;
SELECT *
FROM TEAM WHERE ID=2;
SELECT *
FROM TEAM WHERE ID=3;
 

결국:

1 + N
 

입니다.


8. 해결 방법: Fetch Join

가장 대표적인 해결책입니다.

JPQL:

 
select m
from Member m
join fetch m.team
 

그러면 SQL:

 
SELECT
    M.*,
    T.*
FROM MEMBER M
JOIN TEAM T
ON M.TEAM_ID = T.ID
 

한 번에 가져옵니다.

결과:

SQL 1번
 

정리

방식특징
EAGER 조회 시 무조건 연관 객체 로딩 → N+1 위험 큼
LAZY 실제 사용할 때 조회 → 기본 추천
Fetch Join 필요한 경우 한 번에 조회 → N+1 해결
EntityGraph Fetch Join과 비슷한 해결 방법

실무 JPA에서는 보통:

 
@ManyToOne(fetch = FetchType.LAZY)
 

를 기본으로 사용하고, 조회 화면이나 API에서 필요한 경우:

 
join fetch
 

를 사용해서 N+1 문제를 해결합니다.