JPA N+1 문제, FetchType, Join, Fetch Join 정리

Psj·2026년 7월 21일

F-lab

목록 보기
240/240

1. N+1 문제가 뭔가?

N+1 문제는 JPA에서 엔티티를 조회한 뒤, 그 엔티티가 가진 연관 객체를 채우기 위해 추가 SQL이 여러 번 더 나가는 문제를 말한다.

예를 들어 UserCompany를 가지고 있다고 하자.

@Entity
public class User {

    @Id
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Company company;
}
@Entity
public class Company {

    @Id
    private Long id;

    private String name;
}

DB 구조로 보면 users 테이블에 company_id FK가 있는 형태다.

users
- id
- name
- company_id

company
- id
- name

User 목록을 조회하면 일단 JPA는 users 테이블에서 사용자 데이터를 가져온다.

select
    u.id,
    u.name,
    u.company_id
from users u;

여기까지가 첫 번째 SQL이다.

그런데 조회된 User 객체들이 가진 company 정보가 필요해지는 순간, JPA는 각 company_id에 맞는 Company를 추가로 조회해야 한다.

select
    c.id,
    c.name
from company c
where c.id = ?;

처음 User 조회 SQL 1번이 나갔고, 이후 연관된 Company를 조회하기 위한 SQL이 추가로 N번 나갈 수 있다.

그래서 이 문제를 N+1이라고 부른다.

1번: User 목록 조회
N번: 각 User에 연결된 Company 조회

중요한 점은 N+1이 단순히 반복문 때문에 생기는 문제가 아니라는 것이다. 더 정확히는 JPA가 처음 조회한 엔티티와 그 엔티티의 연관 객체를 어떤 시점에, 어떤 방식으로 로딩하느냐 때문에 생기는 문제다.

반복문은 이 문제가 드러나는 대표적인 상황일 뿐이다. 핵심 원인은 객체 그래프와 SQL 조회 방식 사이의 차이다.


2. 객체 그래프와 테이블 조회 방식의 차이

Java 객체는 연관 객체를 필드로 가진다.

User user = ...
Company company = user.getCompany();

객체 입장에서는 User 안에 Company가 자연스럽게 연결되어 있는 것처럼 보인다.

하지만 DB 입장에서는 users 테이블과 company 테이블이 분리되어 있고, 두 테이블은 FK로 연결되어 있다.

즉, Java 객체 세계에서는 다음처럼 보인다.

User
 └── Company

하지만 DB 세계에서는 다음과 같다.

users.company_id -> company.id

JPA는 이 둘 사이를 매핑해준다. 문제는 User를 조회할 때 Company까지 항상 같이 가져올지, 아니면 나중에 필요할 때 가져올지를 결정해야 한다는 점이다.

이 결정에 관여하는 설정이 FetchType.EAGERFetchType.LAZY다.


3. FetchType.EAGER

FetchType.EAGER는 즉시 로딩이다.

@ManyToOne(fetch = FetchType.EAGER)
private Company company;

이 설정은 User를 조회할 때 company도 즉시 로딩하라는 의미다.

여기서 조심해야 할 부분이 있다.

EAGER연관 객체를 즉시 채워야 한다는 뜻이지, 항상 SQL join으로 한 번에 가져온다는 뜻이 아니다.

JPA는 다음 두 방식 중 하나로 Company를 로딩할 수 있다.

3.1 join으로 한 번에 가져오는 경우

select
    u.id,
    u.name,
    u.company_id,
    c.id,
    c.name
from users u
left join company c on u.company_id = c.id;

이 경우에는 UserCompany를 한 번의 SQL로 가져온다.

3.2 User를 먼저 가져오고 Company를 추가 조회하는 경우

select
    u.id,
    u.name,
    u.company_id
from users u;

그 다음 company_id에 맞춰 회사들을 추가 조회한다.

select
    c.id,
    c.name
from company c
where c.id = ?;

이 방식이 되면 EAGER에서도 N+1 문제가 발생할 수 있다.

즉, EAGER는 N+1을 막는 설정이 아니다. 오히려 원하지 않는 순간에도 연관 객체를 강제로 로딩하기 때문에 성능 문제를 숨겨서 만들 수 있다.


4. FetchType.LAZY

FetchType.LAZY는 지연 로딩이다.

@ManyToOne(fetch = FetchType.LAZY)
private Company company;

이 설정은 User를 조회할 때 Company를 바로 조회하지 않고, 실제로 company의 데이터가 필요해지는 순간에 조회하라는 의미다.

User를 조회할 때는 보통 다음 SQL만 나간다.

select
    u.id,
    u.name,
    u.company_id
from users u;

이 시점에 User 객체 안의 company 필드는 실제 Company 객체가 아니라 프록시 객체일 수 있다.

프록시는 쉽게 말하면 “아직 DB에서 진짜 데이터를 가져오지는 않았지만, 필요하면 가져올 수 있는 대리 객체”다.

User
 └── Company proxy

이후 company의 실제 값이 필요해지면 그때 SQL이 나간다.

user.getCompany().getName();
select
    c.id,
    c.name
from company c
where c.id = ?;

LAZY는 처음 조회를 가볍게 만든다. 하지만 여러 UserCompany가 나중에 필요해지면 그때 N+1이 발생할 수 있다.

따라서 LAZY는 N+1을 완전히 없애는 설정이 아니라, 연관 객체 조회 시점을 뒤로 미루는 설정에 가깝다.


5. 단건 LAZY 조회는 N+1인가?

User 하나를 조회한 뒤 그 Usercompany 하나를 접근하는 상황은 보통 N+1 문제라고 부르지 않는다.

예를 들어 다음 코드가 있다고 하자.

User user = userRepository.findById(1L).orElseThrow();
String companyName = user.getCompany().getName();

company가 LAZY라면 SQL은 대략 다음처럼 나갈 수 있다.

select
    u.id,
    u.name,
    u.company_id
from users u
where u.id = ?;

그리고 user.getCompany().getName()을 호출하는 시점에:

select
    c.id,
    c.name
from company c
where c.id = ?;

즉, User 조회 1번과 Company 조회 1번이 나간다.

User 단건 조회 1번
+ Company 지연 로딩 1번
= 1 + 1

이건 LAZY 로딩으로 인한 추가 조회는 맞지만, 일반적으로 말하는 N+1 문제는 아니다. 그냥 “단건 엔티티의 연관 객체를 나중에 한 번 더 조회했다”에 가깝다.

N+1 문제는 보통 목록 조회에서 드러난다.

List<User> users = userRepository.findAll();

이때 User가 100명 조회되면 처음 SQL은 1번이다.

select
    u.id,
    u.name,
    u.company_id
from users u;

그런데 이후 100명의 사용자에 대해 회사명이 필요해진다고 하자.

List<UserResponse> responses = users.stream()
    .map(user -> new UserResponse(
        user.getId(),
        user.getName(),
        user.getCompany().getName()
    ))
    .toList();

이 경우 각 Usercompany가 LAZY 초기화되면서 추가 SQL이 여러 번 나갈 수 있다.

select * from company where id = ?;
select * from company where id = ?;
select * from company where id = ?;
...

그래서 다음 구조가 된다.

User 목록 조회 1번
+ 각 User의 Company 조회 N번
= N+1

정리하면 다음과 같다.

단건 조회 + 연관 객체 1개 접근
= 보통 N+1이라고 부르지 않음

목록 조회 + 각 엔티티의 연관 객체 접근
= N+1 문제가 발생할 수 있음

따라서 “LAZY면 N+1이 발생한다”라고 단정하면 조금 부정확하다. 더 정확히는 다음 표현이 맞다.

LAZY로 미뤄둔 연관 객체를 여러 엔티티에서 하나씩 접근하면 N+1이 발생할 수 있다.

반대로 EAGER도 N+1을 만들 수 있다. 즉, N+1은 EAGER냐 LAZY냐 하나로만 결정되는 문제가 아니라, 조회한 엔티티 수와 연관 객체를 로딩하는 방식에 의해 결정된다.


6. EAGER와 LAZY의 핵심 차이

구분EAGERLAZY
의미연관 객체를 즉시 로딩연관 객체를 실제로 사용할 때 로딩
User 조회 시 Company바로 채워야 함프록시로 두고 나중에 조회 가능
N+1 가능성있음있음
문제점원하지 않아도 연관 객체를 가져올 수 있음사용하는 시점에 추가 SQL이 터질 수 있음
실무 권장신중히 사용기본값처럼 사용하는 경우가 많음

실무에서는 대부분의 연관관계를 LAZY로 두고, 특정 조회에서 필요한 연관 객체만 명시적으로 가져오는 방식을 선호한다.

@ManyToOne(fetch = FetchType.LAZY)
private Company company;

이렇게 기본 로딩 전략은 가볍게 두고, 정말 필요한 화면이나 API에서 fetch join을 사용한다.


7. 일반 join과 fetch join의 차이

Spring Data JPA나 JPQL을 사용할 때 joinfetch join은 매우 다르다.

SQL만 보면 둘 다 join처럼 보일 수 있다. 하지만 JPA 입장에서 의미가 다르다.

6.1 일반 join

@Query("""
    select u
    from User u
    join u.company c
    where c.name = :companyName
""")
List<User> findUsersByCompanyName(String companyName);

이 쿼리는 company를 join해서 회사 이름을 조건으로 사용한다.

의미는 다음과 같다.

Company 테이블을 조회 조건에 사용해서 User를 찾는다.

즉, 일반 join은 연관 테이블을 조건, 정렬, 필터링 등에 사용하기 위한 join이다.

하지만 이 쿼리가 u.company까지 완전히 초기화해서 가져온다고 보장하지 않는다.

반환 대상은 User다.

select u

따라서 User는 조회되지만, Company가 LAZY라면 user.getCompany().getName()을 호출하는 시점에 추가 SQL이 나갈 수 있다.

일반 join의 목적은 연관 테이블을 쿼리에서 참조하는 것이다.


8. Fetch join

fetch join은 연관 객체를 실제 엔티티 객체에 채워서 가져오라는 의미다.

@Query("""
    select u
    from User u
    join fetch u.company
    where u.id = :id
""")
User findUserWithCompany(Long id);

이 쿼리는 User를 조회하면서 Company도 함께 로딩한다.

SQL은 대략 다음처럼 나간다.

select
    u.id,
    u.name,
    u.company_id,
    c.id,
    c.name
from users u
join company c on u.company_id = c.id
where u.id = ?;

그리고 JPA는 이 결과를 가지고 다음 객체 그래프를 완성한다.

User
 └── Company

따라서 이후에 다음 코드를 실행해도 추가 SQL이 나가지 않는다.

user.getCompany().getName();

이미 Company가 로딩되어 있기 때문이다.

fetch join의 목적은 연관 객체까지 함께 로딩해서 객체 그래프를 완성하는 것이다.


9. 일반 join vs fetch join 한눈에 비교

구분일반 joinfetch join
JPQL 예시join u.company cjoin fetch u.company
목적조건, 필터링, 정렬에 연관 테이블 사용연관 엔티티를 함께 로딩
반환 객체의 연관 필드초기화 보장 안 됨초기화됨
LAZY 필드 접근 시추가 SQL 가능추가 SQL 없음
N+1 해결 여부보통 해결 못 함해결 가능

정리하면 다음과 같다.

일반 join:
쿼리에서 연관 테이블을 사용하기 위한 join

fetch join:
연관 객체까지 같이 가져와서 엔티티에 채우기 위한 join

10. 왜 일반 join은 Company를 가져왔는데도 매핑이 안 된 것처럼 보이나?

이 부분이 헷갈리기 쉽다.

일반 join을 하면 SQL 결과에는 company 테이블이 참여할 수 있다. 그런데 JPQL에서 다음처럼 썼다면:

select u
from User u
join u.company c
where c.name = :companyName

조회 결과의 주인공은 User다.

Company는 조건을 걸기 위해 join에 참여했을 뿐, JPA에게 “이 Company를 User.company 필드에 넣어서 완성해라”라고 말한 것이 아니다.

반대로 fetch join은 JPA에게 명시적으로 말한다.

select u
from User u
join fetch u.company

이 쿼리는 다음 의미다.

User를 조회하되, User.company도 같이 가져와서 User 객체에 채워라.

그래서 일반 join과 fetch join은 SQL의 join 여부보다 JPA의 객체 매핑 의도가 다르다고 이해하는 게 좋다.


11. Spring Data JPA에서 자주 쓰는 형태

10.1 일반 메서드 조회

List<User> users = userRepository.findAll();

이 경우 User만 조회한다.

company가 LAZY라면 Company는 아직 로딩되지 않을 수 있다.

10.2 fetch join 사용

public interface UserRepository extends JpaRepository<User, Long> {

    @Query("""
        select u
        from User u
        join fetch u.company
    """)
    List<User> findAllWithCompany();
}

이렇게 하면 UserCompany를 한 번에 가져온다.

사용자 목록 화면에서 회사명까지 같이 보여줘야 한다면 이런 방식이 적합하다.

10.3 조건이 필요한 fetch join

@Query("""
    select u
    from User u
    join fetch u.company
    where u.status = :status
""")
List<User> findAllWithCompanyByStatus(UserStatus status);

특정 조건의 사용자 목록을 가져오면서 회사 정보까지 필요한 경우다.


12. @ManyToOne에서는 N+1이 어떻게 나타나나?

User -> Company는 보통 @ManyToOne이다.

@ManyToOne(fetch = FetchType.LAZY)
private Company company;

여러 사용자가 각각 회사를 가지고 있다.

User 1 -> Company A
User 2 -> Company B
User 3 -> Company C

User 목록을 먼저 가져오면:

select * from users;

이후 각 UserCompany가 필요할 때:

select * from company where id = ?;
select * from company where id = ?;
select * from company where id = ?;

처럼 추가 조회가 발생할 수 있다.

단, 같은 Company를 여러 User가 공유하고 있고 이미 영속성 컨텍스트에 그 Company가 존재한다면, JPA가 같은 엔티티를 다시 SQL로 조회하지 않을 수도 있다.

그래서 실제 쿼리 수는 데이터 분포, 영속성 컨텍스트 상태, 1차 캐시 상태에 영향을 받는다.

하지만 구조적으로는 N+1 가능성이 있다.


13. @OneToMany에서는 N+1이 어떻게 나타나나?

이번에는 Company가 여러 User를 가진다고 하자.

@Entity
public class Company {

    @Id
    private Long id;

    private String name;

    @OneToMany(mappedBy = "company", fetch = FetchType.LAZY)
    private List<User> users = new ArrayList<>();
}

Company 목록을 조회하면:

select * from company;

각 회사의 사용자 목록이 필요해지는 순간:

select * from users where company_id = ?;
select * from users where company_id = ?;
select * from users where company_id = ?;

처럼 회사 수만큼 추가 조회가 나갈 수 있다.

@OneToMany의 N+1은 특히 조심해야 한다. 컬렉션을 fetch join하면 중복 row와 페이징 문제가 같이 생길 수 있기 때문이다.


14. fetch join의 주의점

fetch join은 N+1을 해결하는 강력한 방법이지만 무조건 쓰면 되는 것은 아니다.

13.1 컬렉션 fetch join과 중복 row

Company -> users처럼 컬렉션을 fetch join하면 SQL 결과는 회사 기준이 아니라 조인 결과 기준으로 늘어난다.

예를 들어 회사 1개에 사용자가 3명 있으면 SQL 결과 row는 3개가 된다.

Company A + User 1
Company A + User 2
Company A + User 3

JPA는 같은 Company를 하나의 엔티티로 합쳐줄 수 있지만, JPQL 결과 리스트에는 중복이 보일 수 있다.

그래서 보통 distinct를 같이 쓴다.

@Query("""
    select distinct c
    from Company c
    join fetch c.users
""")
List<Company> findAllWithUsers();

JPQL의 distinct는 SQL distinct 역할도 하지만, JPA 엔티티 중복 제거에도 관여한다.

13.2 컬렉션 fetch join과 페이징

컬렉션 fetch join을 하면서 페이징을 걸면 문제가 생길 수 있다.

@Query("""
    select c
    from Company c
    join fetch c.users
""")
Page<Company> findPageWithUsers(Pageable pageable);

이런 형태는 위험하다.

왜냐하면 DB의 row 기준 페이징과 JPA의 엔티티 기준 페이징이 어긋날 수 있기 때문이다.

회사 10개를 가져오고 싶은데, join 결과 row는 사용자 수만큼 늘어난다. 이 상태에서 limit을 걸면 회사 10개가 아니라 조인 row 10개가 잘릴 수 있다.

그래서 컬렉션 fetch join과 페이징은 조심해야 한다.

대안으로는 다음 방식을 사용한다.

  • 먼저 ID만 페이징 조회
  • 조회한 ID 목록으로 fetch join
  • @BatchSize 사용
  • Hibernate default_batch_fetch_size 설정
  • DTO 직접 조회

15. N+1 해결 방법들

14.1 fetch join

가장 직접적인 해결책이다.

@Query("""
    select u
    from User u
    join fetch u.company
""")
List<User> findAllWithCompany();

연관 객체가 반드시 필요한 조회에서는 fetch join이 적합하다.

14.2 EntityGraph

Spring Data JPA에서는 @EntityGraph를 사용할 수도 있다.

@EntityGraph(attributePaths = "company")
List<User> findAll();

이 방식은 메서드 이름 기반 쿼리나 기본 repository 메서드에 연관 로딩 전략을 붙이고 싶을 때 유용하다.

14.3 Batch Size

Hibernate에서는 batch size를 설정해서 연관 엔티티를 한 번에 묶어서 조회할 수 있다.

@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Company company;

또는 전역 설정으로:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이렇게 하면 연관 객체를 하나씩 조회하는 대신 in 쿼리로 묶어서 가져올 수 있다.

select
    c.id,
    c.name
from company c
where c.id in (?, ?, ?, ...);

즉, N번의 쿼리를 훨씬 적은 수의 쿼리로 줄일 수 있다.

14.4 DTO 직접 조회

화면이나 API 응답에 필요한 데이터가 정해져 있다면 DTO로 바로 조회하는 것도 좋은 방법이다.

@Query("""
    select new com.example.UserCompanyResponse(
        u.id,
        u.name,
        c.name
    )
    from User u
    join u.company c
""")
List<UserCompanyResponse> findUserCompanyResponses();

이 방식은 엔티티 객체 그래프를 완성하는 목적이 아니라, 필요한 응답 데이터를 바로 가져오는 목적이다.

조회 전용 화면에서는 DTO 조회가 더 명확하고 효율적일 때가 많다.


16. 실무에서 권장하는 기본 방향

15.1 연관관계는 기본적으로 LAZY

실무에서는 대부분 다음 원칙을 따른다.

@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.LAZY)
@ManyToMany(fetch = FetchType.LAZY)

특히 @ManyToOne, @OneToOne은 JPA 기본값이 EAGER라서 직접 LAZY로 바꿔주는 경우가 많다.

@ManyToOne(fetch = FetchType.LAZY)
private Company company;

기본값을 EAGER로 두면 예상하지 못한 추가 조회가 생기기 쉽다.

15.2 필요한 쿼리에서만 fetch join

회사 정보가 필요한 사용자 목록 API라면:

@Query("""
    select u
    from User u
    join fetch u.company
""")
List<User> findAllWithCompany();

회사 정보가 필요 없는 사용자 목록 API라면:

List<User> findAll();

이렇게 조회 목적에 따라 다르게 가져가는 것이 좋다.

15.3 API 응답은 엔티티를 그대로 반환하지 않기

엔티티를 그대로 JSON으로 반환하면 LAZY 로딩, 순환 참조, 의도치 않은 추가 쿼리 문제가 생길 수 있다.

보통은 DTO로 변환해서 반환한다.

public record UserResponse(
    Long userId,
    String userName,
    String companyName
) {
}

17. 핵심 요약

N+1 문제는 엔티티를 1번 조회한 뒤, 연관 객체를 채우기 위해 추가 SQL이 N번 더 나가는 문제다.

EAGER는 연관 객체를 즉시 로딩한다. 하지만 항상 join으로 가져오는 것은 아니며, 추가 select가 발생해서 N+1이 생길 수 있다.

LAZY는 연관 객체를 실제로 사용할 때 로딩한다. 처음 조회는 가볍지만, 나중에 연관 객체를 여러 개 접근하면 N+1이 생길 수 있다.

일반 join은 연관 테이블을 조건, 정렬, 필터링에 사용하기 위한 것이다.

fetch join은 연관 엔티티까지 함께 로딩해서 객체 필드에 채우기 위한 것이다.

실무에서는 연관관계를 기본적으로 LAZY로 두고, 필요한 조회에서만 fetch join, EntityGraph, batch size, DTO 조회 등을 선택해서 사용한다.


18. 가장 중요한 한 문장

join은 SQL 조회 조건을 위한 것이고, fetch join은 JPA 엔티티의 연관 객체를 같이 채우기 위한 것이다.

profile
Software Developer

0개의 댓글