JPA N+1 문제, 해결방법

GEONNY·2024년 8월 21일
0
post-thumbnail

JPA 사용 시 항상 신경써야 하는 N + 1 문제 에 대해서 알아보겠습니다.

📌N+1 문제란?

JPA 가 연관관계를 처리하는 방식에서 비롯되는 성능 문제로, 한번의 쿼리로 조회된 엔티티와 연관된 엔티티들을 가져올 때 추가적으로 N개의 쿼리가 실행되어 총 N + 1 개의 쿼리가 발생하는 현상을 말한다.

예를들어, 아래와 같이 Member table 에 10개의 데이터가 있습니다.
Member table

10명의 Member 가 있고 각 멤버들은 권한을 가지고 있습니다. 데이터 상으로 보면 ROLE_ADMIN, ROLE_MANAGER, ROLE_USER, ROLE_TEST 4개의 권한을 각각 가지고 있는 것으로 보입니다. Member Entity 를 한번 확인해보죠.

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table
@Entity(name = "member")
public class Member extends BaseEntity implements Persistable<String> {

    @Id
    @Column(name = "member_id")
    private String memberId;

	//생략

    @ManyToOne
    @JoinColumn(name = "authority_cd")
    private Authority authority;
    
    //생략
}

Member table 에 authority_cd 필드 대신, Authority Entity 와 연관관계가 설정되어 있습니다.
JPA가 어떻게 쿼리를 생성하는지 확인하기 위해 application.yml에 옵션을 추가하겠습니다.

spring:
  jpa:
    show-sql: true

위 옵션 설정 시 Hibernate 가 생성하는 모든 쿼리를 콘솔에 출력해 줍니다. default 가 false 이기 때문에 지금까지는 콘솔에서 쿼리를 확인할 수 없었죠. 이제 전체 Member를 조회하고 콘솔창을 확인해보겠습니다.

총 5번의 쿼리가 출력되었습니다. Mybatis 나 기존에 쿼리를 작성하던 개발자라면 left join 으로 한번만 조회하면 될건데 왜 5번이나 조회하는거지? 라는 의문을 가질 수 있습니다. 이는 연관관계가 설정된 Entity 조회 시 JPA의 로직을 알아야 합니다.

📌연관관계가 설정된 Entity 조회 시 JPA 로직

📍1. 기준 Entity 조회

위의 예시에서 기준 Entity는 Member 입니다. 여기서 연관관계에 지정된 fetch 전략에 따라 연관된 Entity를 즉시 로드할지 지연할지 결정합니다.

	@ManyToOne
    @JoinColumn(name = "authority_cd")
    private Authority authority;

📍2. 연관관계 Entity 조회

@ManyToOne 에 아무 속성도 설정하지 않았습니다. One 으로 끝나는 연관관계의 default fetch 전략은 FetchType.EAGER 입니다. 그러니 기준 Entity와 함께 연관관계 Entity를 즉시 조회 합니다. 참고로 FetchType.LAZY 설정 시 연관관계 Entity는 proxy 로 대체되고, 실제 연관관계 Entity에 접근할 때만 쿼리를 실행하여 Entity로 대체 됩니다.

기준 Entity인 Member가 한번 조회되고, 즉시 로딩 fetch 전략에 의해 Member가 가지고 있는 authority_cd FK 데이터 개수 만큼의 쿼리를 실행하여 Member Entity 에 추가하게 되는 것 입니다.

📌N+1 해결 방법

해결방법을 알아보기 전 쿼리를 보기좋게 포멧팅해주는 옵션을 추가하겠습니다.
spring.jpa.properties.hibernate.format_sql

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

📍Fetch Join

JOIN FETCH 를 사용해서 한번의 쿼리로 연관된 엔티티를 함께 로딩하게 설정합니다.
domain.member.MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {
    @Query(value = "SELECT m FROM member m JOIN FETCH m.authority")
    @Nonnull
    List<Member> findAll();
}

📍@BatchSize

Hibernate Annotation 인@BatchSize 를 설정하여 한번에 로딩할 Entity 수를 설정합니다. batchSize 설정 시 쿼리의 빈도를 줄일 수 있습니다. 하지만 위와 같이 Collection 이 아닌 단일 Entity 연관관계에서는 사용하지 못하며 사용 시 org.hibernate.AnnotationException 이 발생하게 됩니다.

Caused by: org.hibernate.AnnotationException: 
error processing @AttributeBinderType annotation '@org.hibernate.annotations.BatchSize(size=x)

그럼 양방향 관계인 Authority Entity 에 추가하여 확인해보겠습니다.
entity.Authority

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table
@Entity(name = "authority")
public class Authority extends BaseEntity {

    @Id
    @Column(name = "authority_cd")
    private String authorityCode;

    @Column(name = "authority_nm")
    private String authorityName;

    @OneToMany(mappedBy = "authority", fetch = FetchType.LAZY)
    @OrderBy("memberName asc")
    @BatchSize(size = 4)
    private Set<Member> members = new HashSet<>();
}

전체 권한 조회 시 기준인 Authority 조회 1번, 로드할 Member 의 데이터가 4개이기 때문에 size를 4로 설정하여 매핑된 Member 조회 1 번이 발생합니다. 이처럼 @BatchSize 는 size 속성의 값으로 묶어서 Entity를 로드하게 됩니다. (size 를 2로 변경하면 총 4개중 2개로 묶어서 2번의 쿼리가 발생하게 됩니다.)

🎈default batch size

application.yml 에 default batch size 를 설정할 수 있습니다. default batch size 설정 시 단일 객체에도 적용되며, 혹시 발생할 수 있는 N + 1 문제를 보완할 수 있습니다.

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 50

📍Entity Graph

@EntityGraph 를 사용하여 특정 조회 method 에만 fetch 전략을 지정할 수 있습니다. @EntityGraph의 attributePaths 속성 값으로 연관관계를 설정한 필드의 변수명을 입력합니다. 연관관계가 여러개인 경우 배열로 지정할 수 있습니다.
domain.member.MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

    @EntityGraph(attributePaths = "authority")
    @Nonnull
    List<Member> findAll();
}

🎈Named Entity Graph

Entity 에 @NamedEntityGraph 를 설정하고 repository 에서 해당 EntityGraph로 설정해도 결과는 동일합니다. 복잡한 연관관계 fetch 전략 설정 시 이 방법이 권장됩니다.
entity.Member

@NamedEntityGraph(
        name = "Member_graph",
        attributeNodes = {
                @NamedAttributeNode("authority")
        }
)
public class Member extends BaseEntity implements Persistable<String> {
//이하 생략..

domain.member.MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

    @EntityGraph(value = "Member_graph")
    @Nonnull
    List<Member> findAll();
}

queryDsl 에서의 Entity Graph

queryDsl 에서는 query에 hint 로 EntityGraph를 전달하여 N + 1을 해결할수 있습니다. 추 후 queryDsl 적용할 때 자세히 알아보도록 하죠.

private void checkNamedEntityGraph(JPAQuery<T> query) {
    String namedEntityGraphName = getNamedEntityGraph();
    if (StringUtils.isNotEmpty(namedEntityGraphName)) {
        query.setHint("jakarta.persistence.loadgraph",
        	this.entityManager.getEntityGraph(namedEntityGraphName));
    }
}

📌Conclution

N+1을 해결하지 않으면 많은 양의 데이터를 조회할 때 성능상의 문제가 발생할 수 있습니다. 위의 3가지 방법 중 상황에 따라 적절하게 선택하여 Database 조회 성능을 최적화 해야 합니다.

profile
Back-end developer

0개의 댓글