[Spring/JPA] - N + 1 문제

CodeByHan·2025년 2월 11일

스프링

목록 보기
13/33

다른 사람들의 프로젝트를 보다가 N+1 문제 해결에 대한 내용이 많아 궁금해졌다.
평소에 프로젝트를 진행하면서 쿼리가 예상보다 많이 로그에 찍히는 것을 보긴 했지만, 그때는 성능에 미치는 영향을 깊게 생각하지 않았다.
하지만 이번 기회에 N+1 문제가 DB와 애플리케이션 성능에 큰 영향을 준다는 것을 깨달았고, 같은 실수를 반복하지 않기 위해 정리해 두려고 한다.

N + 1 문제란?

연관관계가 있는 엔티티를 조회할 때 조회된 개수 N개 만큼의 쿼리가 추가로 발생하는 것

이렇게 정의되어 있는데 쉽게 생각하면

엔티티 조회 쿼리(1번) + 조회된 엔티티의 개수(N개) 만큼 연관된 엔티티를 조회하기 위한 추가 쿼리 (N번)

이라고 생각하면 편하다.

  • 여러명의 선수를 자지고 있는 팀
  • 한개의 팀을 가지고 있는 선수

이런 관계가 존재할 때,

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private Set<Player> players = new LinkedHashSet<>();

    public Team(String name) {
        this.name = name;
    }

}
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Player {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @ManyToOne
    private Team team;

    public Player(String name) {
        this.name = name;
    }
}

이런 연관 관계가 있는 엔티티들이 존재할 때 팀들을 조회해 보면

 @Test
    void exampleTest() {
        Set<Player> players = new LinkedHashSet<>();
        for (int i = 0; i < 10; i++) {
            players.add(new Player("player" + i));
        }
        catRepository.saveAll(players);  // 저장

        List<Team> teams = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Team team = new Team("team" + i);
            team.setPlayers(players);
            teams.add(team);
        }
        teamRepository.saveAll(teams);  // 저장

        entityManager.clear(); // 영속성 컨텍스트 초기화

        System.out.println("-------------------------------------------------------------------------------");
        List<Team> everyTeams = teamRepository.findAll();
        assertFalse(everyTeams.isEmpty());
    }
SELECT t1_0.id, t1_0.name FROM team t1_0;

SELECT p1_0.team_id, p1_0.id, p1_0.name FROM player p1_0 WHERE p1_0.team_id=?;
SELECT p1_0.team_id, p1_0.id, p1_0.name FROM player p1_0 WHERE p1_0.team_id=?;
SELECT p1_0.team_id, p1_0.id, p1_0.name FROM player p1_0 WHERE p1_0.team_id=?;
...

이렇게 는 팀 목록을 가져오는 단일 쿼리(1)가 실행되고
팀마다 개별적으로 선수 데이터를 조회하는 쿼리가 실행된다.

결과적으로
• team을 조회하는 1개의 쿼리 실행
• team의 개수(N)만큼 player 조회 쿼리가 추가 실행

즉시 로딩(EAGER)? 지연 로딩(LAZY)?

즉시 로딩과 지연 로딩은 DB에서 데이터를 조회하는 방식중 하나로, 객체 간의 연관 관꼐를 어떻게 로딩하고 관리 할 것인지에 대한 개념

즉시 로딩 (Eager Loading)

  • 엔티티를 조회할 때, 해당 엔티티와 연관된 모든 엔티티를 즉시 함께 로딩하는 방식
  • 이를 통해 객체 간의 관계를 편리하게 활용 가능하지만, 불필요한 데이터까지 로딩되어 성능 저하 유발 가능

지연 로딩(LAZY Loading)

  • 연관된 엔티티를 처음에는 조회하지 않고, 실제 해당 엔티티가 필요한 시점에 조회하는 방식
  • 이때, 연관된 엔티티는 프록시 객체로 초기화되며, 해당 엔티티에 접근하는 시점에 실제 데이터베이스 조회가 발생

각 연관관계의 default 속성

@ManyToOne : EAGER
@OneToOne : EAGER
@ManyToMany : LAZY
@OneToMany : LAZY

그러면 지연 로딩을 사용하면 되는거 아닌가요??
-> 땡!!

해결 방안

Fetch Join(패치 조인)

N + 1 문제가 발생하는 이유는 한쪽 엔티티만 조회하고 연결된 다른 테이블을 또 조회하기 때문이다.

미리 두 테이블을 조인(Join) 하여 한번에 모든 데이터를 가져오는 것이 가능하면 N + 1 문제가 발생하지 않을 것이다.

JPQL로 fetch Join을 적용해보자면

 @Query("select distinct o from Team o join fetch o.players")
 List<Team> findAllJoinFetch();
select distinct t1_0.id,t1_0.name,p1_0.team_id,p1_0.id,p1_0.name from team t1_0 join player p1_0 on t1_0.id=p1_0.team_id

이렇게 쿼리가 한번 수행되고 Team과 player의 데이터를 가져오는 것을 확인할 수 있다.

하지만 단점도 존재한다.

Fetch Join(패치 조인)의 단점

  • 쿼리 한번에 모든 데이터를 가져오기 때문에 Paging Api(Pageable 사용 불가)
  • 1:N 관계가 두 개 이상인 경우 사용 불가
  • 패치 조인 대상에게 별칭(as) 부여 불가능

@Entity Graph

  • @EntityGraph 의 attributePaths는 같이 조회할 연관 엔티티명을 적기

  • ,(콤마)를 통해 여러 개를 전달 가능

@EntityGraph(attributePaths = {"palyer"})@Query("select DISTINCT o from Team o")
List<Team> findAllEntityGraph();
 select distinct t1_0.id,t1_0.name,p1_0.team_id,p1_0.id,p1_0.name from team t1_0 join player p1_0 on t1_0.id=p1_0.team_id

결과를 보면 쿼리가 1번만 발생하고 미리 Team과 player 데이터를 조인해서 가져오는 것을 볼 수 있음

Fetch join의 경우 inner join을 하는 반면에 EntityGraph는 outer join을 기본으로 한다.

Fetch Join과 EntityGraph 사용시 주의점

FetchJoin과 EntityGraph는 공통적으로 카테시안 곱(Cartesian Product)이 발생 하여 중복 가능

해결법

  1. JPQL에 DISTINCT 를 추가하여 중복 제거
  2. oneToMany 필드 타입을 Set으로 선언하여 중복 제거 (순서 보장이 필요한 경우 LinkedHashSet을 사용)

참고 : [JPA] N+1 문제가 발생하는 여러 상황과 해결방법
참고 : [JPA] N+1 문제 원인 및 해결방법 알아보기

profile
노력은 배신하지 않아 🔥

0개의 댓글