티스토리 뷰

Spring

JPA N + 1 원인 및 해결 방법

alsdl0629 2024. 8. 2. 17:53

N + 1 이란?

쿼리가 1번만 나가길 기대했는데 N번이 추가적으로 실행돼서 애플리케이션 성능에 안 좋은 영향을 끼치는 문제입니다.

 

각 팀에 속한 회원 수를 가져와야 하는 요구사항이 있다고 가정해 보겠습니다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Team team;
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

    @Builder
    public Team(String name, List<Member> members) {
        this.name = name;
        this.members = members;
    }
}

 

@Transactional(readOnly = true)
public List<Integer> findMemberCount() {
    List<Team> teams = teamRepository.findAll();

    return teams.stream()
        .map(team -> {
            int memberCount = team.getMembers().size();
            log.info("{}의 회원 수 = {}", team.getName(), memberCount);
            return memberCount;
        }
    ).collect(Collectors.toList());
}

팀별 회원 수를 조회하는 서비스 로직입니다.

@DisplayName("각 팀의 회원 수를 조회한다")
@Test
void test() {
    List<Integer> memberCountList = teamService.findMemberCount();

    assertThat(memberCountList.size()).isEqualTo(10);
}

 

팀 10개, 각 팀별 회원 10개씩 더미 값을 넣고 진행했습니다.

 

테스트 코드를 실행시키면 팀 엔티티를 가져오는 쿼리(1개) + 각 팀별 회원을 가져오는 쿼리(10개)가 발생한 것을 확인할 수 있습니다. (1 + N)

 

즉시 로딩으로 가져오면 N + 1을 해결할 수 있지 않을까?

@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();

기본적으로 XXXToMany 어노테이션의 fetch 타입은 지연 로딩이기 때문에 즉시 로딩으로 변경 후 테스트를 다시 해보겠습니다.

 

 

똑같이 N + 1 문제가 발생합니다.

 

N + 1 발생하는 이유

JPA에서는 SQL을 추상화한 JPQL이라는 객체지향 쿼리 언어를 제공합니다. 

JPQL은 지정된 엔티티를 먼저 조회하고(fetch type과 무관) 이후에 해당 엔티티와 연관 관계가 맺어진 엔티티의 fetch type에 따라 추가 쿼리가 실행됩니다.

 

즉시 로딩은 지정된 엔티티를 먼저 조회한 후, 즉시 로딩으로 연관 관계가 맺어진 엔티티를 추가로 조회합니다. 이때 N + 1이 발생하게 됩니다.

 

지연 로딩은 지정된 엔티티를 먼저 조회한 후, 연관 관계가 맺어진 엔티티는 프록시 형태로 가져오지만 프록시를 참조하는 순간 초기화(DB에서 조회) 되어서 N + 1이 발생합니다.

 

 

N + 1 해결 방법

fetch join

@Query("select t from Team t join fetch t.members")
List<Team> findAllTeamWithFetchJoin();

fetch join 결과

 

fetch join을 사용하면 fetch 타입에 상관없이 join을 사용해 하나의 쿼리로 모든 데이터를 한 번에 가져오기 때문에 N + 1을 해결할 수 있습니다.

 

join과 fetch join 차이

@Query("select t from Team t join t.members")
List<Team> findAllTeamWithJoin();

 

우선 join을 사용해 실행시켜 보겠습니다.

join 결과

 

join으로 엔티티를 한 번에 가져오길 기대했지만 N + 1이 발생합니다.

처음 실행된 쿼리를 자세히 보면 팀 엔티티의 컬럼만 가져오는 것을 확인할 수 있습니다.

 

fetch join join
연관된 데이터를 한 번에 가져오고 싶을 때 (N + 1 해결) 조건절에만 연관 관계가 쓰이고, 연관된 데이터는 필요하지 않을 때 

 

 

 

N + 1을 해결해 주는 fetch join이지만 단점도 존재합니다.

 

fetch join 대상 조건절 사용 시 데이터 무결성이 깨질 수 있음

팀에 성이 김 씨인 회원들을 fetch join을 사용해 조회해 보겠습니다.

(회원 10명 중 성이 김 씨인 회원은 3명이라고 가정)

select t from Team t join fetch t.members m where m.name like '김%'
// fetch join과 on절을 같이 사용하면 오류가 발생해서 조건을 걸기 위해 where절을 사용했습니다.
// on 절을 지원하지 않는 이유는 데이터 일관성이 깨질 수 있기 때문에 (밑에서 자세히 설명)

 

fetch join은 연관된 데이터를 모두 가져와 영속성 컨텍스트에 저장합니다.

위에서 fetch join을 사용했기 때문에 영속성 컨텍스트는 총 회원 수 10명이 저장되길 기대했지만 like로 필터링 돼서 3명만 저장됩니다. 즉, 객체의 상태와 DB의 상태 일관성이 깨지게 됩니다. (DB에 있는 데이터보다 적음)

 

최악의 경우 flush(영속성 컨텍스트의 변경 내용을 DB에 반영)가 발생하면 데이터가 삭제돼서 데이터 무결성이 깨질 수 있습니다.

 

 

또한 JPA 2차 캐시를 사용하면 문제가 생길 수 있습니다.

위에 쿼리가 실행됐기 때문에 필터링된 팀과 회원 정보가 2차 캐시에 저장됩니다. 그리고 다른 트랜잭션에서 필터링되지 않은 팀과 회원을 가져와도 2차 캐시에 이미 같은 팀 식별자가 존재하기 때문에 가져온 내용을 버리고 2차 캐시에 저장된 결과를 사용해 예상치 못한 결과를 얻을 수 있습니다.

 

JPQL 동작 플로우 (영속성 컨텍스트에서는 식별자 값으로 식별)
1. JPQL을 호출하면 DB 먼저 조회
2. 조회된 결과가 영속성 컨텍스트에 존재하면 1번에서 조회한 결과를 버림

 

그렇기 때문에 fetch join 대상을 조건절에서 사용할 때는 주의해야 합니다!

fetch join 대상이 아닌 엔티티는 마음껏 조건절에 사용해도 됩니다.

 

이외에도 XXXToMany 관계가 2개 이상이면 fetch join 사용 불가, 일대다 관계에서 feth join 사용 시 페이징 API 사용 불가(모든 데이터 조회 후 메모리에서 페이징 처리, BatchSize로 해결 가능) 문제가 존재합니다.

 

 

EntityGraph

@EntityGraph(attributePaths = "members")
@Query("select t from Team t")
List<Team> findAllTeamWithEntityGraph();

EntityGraph 결과

entity graph를 사용하면 fetch 타입에 상관없이 outer join을 사용해 하나의 쿼리로 모든 데이터를 한 번에 가져오기 때문에 N + 1을 해결할 수 있습니다.

 

attributePaths 속성에는 같이 조회할 엔티티 필드명을 넣으면 됩니다.

 

BatchSize

@BatchSize(size = 10)
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();

또는

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 10

BatchSize 결과

 

BatchSize를 사용하면 where절이 같은 select 쿼리를 in절로 묶을 수 있습니다.

size 속성은 in절에 들어갈 수 있는 최대 개수를 정하는 역할을 합니다.

 

팀이 10개 존재하고, size가 10이면 한 개의 쿼리로 처리할 수 있지만, size가 5라면 두 번의 쿼리가 실행됩니다.

 

 

마무리

코드로 다루진 않았지만 QueryDsl과 같은 쿼리 빌더와 DTO를 통해 N + 1을 해결할 수 있습니다. 엔티티를 가져오는 게 아니어서 객체의 상태와 DB의 상태를 일관되게 유지할 필요가 없기 때문입니다.

 

결론: fetch 타입을 지연로딩으로 설정한 후 fetch join or 쿼리 빌더 + DTO로 데이터를 조회하고, 특별한 경우 BatchSize를 사용하자!

 

도움받은 글 🙇🙇🙇

https://www.inflearn.com/community/questions/15876/fetch-join-%EC%8B%9C-%EB%B3%84%EC%B9%AD%EA%B4%80%EB%A0%A8-%EC%A7%88%EB%AC%B8%EC%9E%85%EB%8B%88%EB%8B%A4

https://www.youtube.com/watch?v=ni92wUkAmQI

https://tecoble.techcourse.co.kr/post/2023-11-01-jpa-fetch-join/

https://velog.io/@ch4570/JPA-N-1-%EB%AC%B8%EC%A0%9C%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B0%A9%EC%95%88#-n--1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%B4%EB%B3%B4%EC%9E%90

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함