관리 메뉴

백엔드 엔지니어 이재혁

[JPA] N+1 본문

Java

[JPA] N+1

alex00728 2025. 9. 15. 13:14

Team 테이블과 Team에 연관관계에 있는 Member 테이블이 있다고 하자.

 

Team 테이블에서 Team A, Team B, Team C의 모든 Member를 조회하고 싶다면, 보통 다음과 같은 쿼리를 실행할 것이다.

SELECT m.* FROM Team t
LEFT JOIN Member m ON t.id = m.team_id
WHERE t.name IN ('Team A', 'Team B', 'Team C');

 

하지만 JPA는 이렇게 조회하지 않는다.

 

JPA의 실행방법

SELECT * FROM Team
WHERE name IN ('Team A', 'Team B', 'Team C');

SELECT * FROM Member
WHERE team_id = 1;

SELECT * FROM Member
WHERE team_id = 2;

SELECT * FROM Member
WHERE team_id = 3;

 

  1. Team에서 Team 이름이 Team A, Team B, Team C인 팀들을 조회한다.
  2. 그 조회 결과를 통해 team_id 목록을 확보하고, 각 team_id를 따로 SELECT 한다.

따라서, N+1 정확히는 1+N (Team 조회 1번 + Member 조회 N번) 문제가 발생한다.

 

왜 이렇게 하지??

처음 N+1 문제를 확인했을 때는 굳이 이렇게 할 이유가??? 라는 생각이 들었다. JPA의 등장 배경부터 알아보자.

JPA의 등장 배경

JPA는 객체 지향과 관계 기반의 차이를 메우기 위해 등장했다.

  • 애플리케이션은 객체 지향 언어(Java, C# 등)로 작성된다.
  • 데이터는 관계형 데이터베이스(RDB)에 저장된다.
  • 양쪽 모델의 차이:
    • 객체: 상속, 연관관계 (참조 기반)
    • RDB: 외래 키, 조인 (관계 기반)

이 차이를 매번 수작업 SQL로 풀다 보니 SQL 중심의 개발이 되고, 객체지향적인 장점을 살리기 어려움

 

JPA 객체 중심 개발

  • SQL 대신 객체 그래프 탐색으로 데이터를 다룬다.
  • 연관관계를 코드에서 참조로 표현할 수 있다.

예시)

기존: Member 테이블 Team_id라는 FK

JPA: FK 대신, Team 객체 안에 Collection 형태로 Member를 저장

 

개발자는 `team.getMembers()`처럼 객체 탐색을 사용

JPA는 불필요한 데이터를 불러오지 않기 위해 연관 엔티티를 필요할 때 불러오는 전략(LAZY)을 기본으로 둠

 

연관 관계 조회 실제 코드

실제 사용 예시를 보면, 우리는 다음과 같은 코드를 사용한다.

for (Team team : teams) { // 1. JPA는 이 시점에 Member 엔티티가 필요한지 모름
    System.out.println("Team: " + team.getName());
    for (Member member : team.getMembers()) { // 2. 이제서야 필요함을 안다
        System.out.println(" -> Member: " + member.getUsername());
    }
}

 

  1. JPA는 1번 시점에 Member 엔티티가 필요한지 모름
  2. 2번 시점이 되어서야 각 Member 엔티티가 필요함을 안다 (하나하나씩 조회)

엔티티마다 독립적으로 조회하게 되는 것이다. 참조 방식으로 조회할 때 Team과 Member를 개별적으로 조회하게 된다.

 

부작용 (N+1)

지연 로딩은 "연관 데이터는 실제로 접근하는 순간에 초기화한다"는 의미다.
불필요한 데이터를 미리 가져오지 않아 효율적일 수 있지만, 컬렉션 연관(예: Team.members)처럼 부모 엔티티 N개 각각에서 연관을 처음 접근하는 경우에는 문제가 된다.


이때 쿼리는 보통 이렇게 된다.

  • 1번: 부모 목록 조회 (`SELECT * FROM team`)
  • N번: 각 부모에 대해 연관 테이블 조회 (`SELECT * FROM member WHERE team_id=?`) × N

"언제 필요한지" 프레임워크가 예측할 수 없어서 기본적으로 최소한만 먼저 가져오도록(LAZY) 설계된 것이다.

 

중요한 점은 "LAZY 연관을 최초 접근할 때마다" 추가 쿼리가 실행된다는 것이다.

이 점만 기억해둔다면, JPA의 N+1이 어디서 발생하는지 판단할 수 있을 것이다.

 

해결 방법들

개발자가 미리 "이 데이터들이 필요해"라고 선언적으로 미리 알려주는 방식으로 N+1 문제를 해결한다.

1. Fetch Join / @EntityGraph

한 번의 쿼리로 필요한 연관까지 로딩하기

다음과 같이 `@EntityGraph`로 지정하면 members 하위 참조 객체도 같이 불러온다.

public interface TeamRepository extends JpaRepository<Team, Long> {
    @EntityGraph(attributePaths = {"members"})
    List<Team> findAll();
}

 

JPQL로 디테일하게 설정할 수도 있다.

public interface TeamRepository extends JpaRepository<Team, Long> {
    @Query("select distinct t from Team t left join fetch t.members")
    List<Team> findAllWithMembers();
}

 

2. Batch Fetching

전역 기본값 설정: `spring.jpa.properties.hibernate.default_batch_fetch_size=50`

개별 설정: `@BatchSize` 붙이기
N개를 IN 쿼리 몇 번으로 묶어 N+1을 크게 완화(예: N=100, 배치=50 → 추가 쿼리 2회)

@BatchSize(size = 50)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;

 

이 문제는 N+1 문제를 완전히 해결하는 것은 아니지만, 문제를 크게 완화해준다.

Fetch Join이 한 번에 너무 많이 조회하는 문제를 방지할 수 있는 중간점 해결책이라고 생각된다.