N+1 발생 원인과 해결 방안

Jun_k·2026년 1월 14일

Java

목록 보기
6/7
post-thumbnail

N+1 문제는 뭘까?

JPA의 N+1 문제는 "처음 1번 조회한 결과 (N개)에 대해,
연관 엔티티를 꺼내는 순간 추가 쿼리가 N번 더 나가는 현상" 입니다.

왜 발생하는 걸까?

JPA(실질적으론 Hibernate 같은 구현체)
연관 관계를 보통 Lazy(지연 로딩)으로 두고,
연관 엔티티에 접근 -> 쿼리를 날려서 가져옵니다.

코드 예시

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY) // 핵심: 지연 로딩
    private Team team;

    // getter...
}

@Entity
public class Team {
    @Id
	@GeneratedValue
    private Long id;
    private String name;

    // getter...
}

문제가 되는 코드

List<Member> members = memberRepository.findAll(); // 1번 쿼리

for (Member m : members) {
    System.out.println(m.getTeam().getName()); // 여기서 N번 쿼리 추가 발생 가능
}

실제로 나가는 SQL의 흐름

  • 멤버 목록을 조회한다. (1번)
 select * from member;
  • Loop를 돌면서 getTeam() 접근할 때마다 팀 조회 (N번)
select * from team where id = ?;  -- 멤버 1
select * from team where id = ?;  -- 멤버 2
...
select * from team where id = ?;  -- 멤버 N
  • 멤버가 100명이면 101번 쿼리 발생 같은 상황이 발생합니다.

한번에 다 가져오면 안되는 걸까?

  • 모든 연관관계를 처음부터 다 Join해서 가져오는 상황
    • 필요하지 않은 데이터를 항상 가져오게 됨 (낭비)
    • Join이 늘어날수록 중복 row 증가 / 페이징 처리 어려움

  • 때문에 기본 전략은
    • "자, 일단 본체만 가져오고, 나머지는 필요할 때, 요청을 할때 가져오자" 인데
      이 "필요할 때"가 Loop에서 N번 반복되면 N+1이 됩니다.

이 문제를 어떻게 해결해야 할까?

해결책 A) Fetch Join (실무에서 많이 사용)

// Java
@Query("select m from Member m join fetch m.team")
List<Member> findAllWithTeam();
-- sql
select m.*, t.* 
from member m
join team t on m.team_id = t.id;

❇️ 장점: 쿼리 하나만 작성하면 되기에 간편하고, N+1을 직접 제거합니다.
⚠️ 주의

  • 1:N (@OneToMany) 을 fetch join 하면 중복된 row가 발생 가능
  • fetch join + 페이징 은 컬렉션에서 위험 / 제약이 큽니다.
    (중복된 row가 많이 발생하기에 DB의 row를 기준으로 하는
    LIMIT/OFFSET 페이징에서 중복이나 누락 문제가 발생합니다.)

해결책 B) EntityGraph

@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

❇️ 장점: 레포지토리 메서드 이름을 유지하면서 fetch 전략만 바꿈
⚠️ 주의: 복잡한 조건/조인 최적화는 fetch join이 더 직관적일 때가 많음


해결책 C) DTO 프로젝션(필요한 컬럼만) (API 응답에 효율적)

엔티티를 다 가져오지 않고, 필요한 데이터만 SELECT 해서 DTO로 받습니다.

public record MemberTeamDto(Long memberId, String memberName, String teamName) {}

@Query("""
select new MemberTeamDto(m.id, m.name, t.name)
from Member m join m.team t
""")
List<MemberTeamDto> findMemberTeamDtos();

❇️ 장점: DTO로 딱 필요한 것만 가져오니깐 성능/전송량 최적화, API 응답에 깔끔
⚠️ 주의: "도메인 로직을 엔티티에 두고 활용"하는 흐름과는 맞지 않을 수 있음


해결책 D) Batch Size (N을 줄여서 "1 + N/배치"로 완화)

Hibernate 설정으로 "LAZY 로딩을 할 때, 한 건씩 말고 IN 쿼리로 묶어서 부탁해"를 적용

  • 설정 예시 (둘 중 하나 사용)
    • hibernate.default_batch_fetch_size=100
    • 필드/엔티티에 @BatchSize(size=100)
select * from team where id in (?, ?, ?, ...);

설정을 사용했다면 getTeam()이 여러 번 호출이 되어도
Hibernate가 어느 시점에 team을 모아서 묶음 조회를 시도합니다.

❇️ 장점: 코드 변경 적고, 컬렉션 로딩에도 도움
⚠️ 주의: “N+1 제거”가 아니라 “N+1 완화”입니다.
상황에 따라선 쿼리가 여러 번 나갈 수도 있습니다.


profile
개발을 즐겨보자.

0개의 댓글