본문 바로가기
스프링

스프링 - N + 1 문제

by 개발자 포비 2024. 12. 17.

JPA N+1 문제와 해결 방안

N+1 문제란?

N+1 문제는 JPA에서 발생하는 대표적인 성능 이슈입니다. 연관 관계가 설정된 엔티티를 조회할 때 조회된 데이터 갯수(N)만큼 추가로 조회 쿼리가 발생하는 현상을 말합니다.

예시 상황

@Entity
public class Answer {
    @ManyToOne(fetch = FetchType.LAZY)
    private Question question;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}

// Repository
List<Answer> answers = answerRepository.findAll();
// 첫 번째 쿼리: SELECT * FROM answer

answers.forEach(answer -> {
    String title = answer.getQuestion().getTitle();  // N번의 추가 쿼리 발생
    String username = answer.getUser().getUsername(); // N번의 추가 쿼리 발생
});

위 코드는 다음과 같은 쿼리를 발생시킵니다:

  1. Answer 목록 조회 쿼리 1번
  2. Question 조회 쿼리 N번
  3. User 조회 쿼리 N번

해결 방안 1: Fetch Join

JPQL을 사용한 Fetch Join

@Query("SELECT a FROM Answer a " +
       "JOIN FETCH a.question q " +
       "JOIN FETCH a.user u")
List<Answer> findAllWithQuestionAndUser();

Spring Data JPA에서 @EntityGraph 사용

@EntityGraph(attributePaths = {"question", "user"})
List<Answer> findAll();

생성되는 SQL:

SELECT a.*, q.*, u.* 
FROM answer a
LEFT JOIN question q ON q.id = a.question_id
LEFT JOIN user u ON u.id = a.user_id

해결 방안 2: QueryDSL 적용

기본 설정

// build.gradle
dependencies {
    implementation 'com.querydsl:querydsl-jpa'
    annotationProcessor 'com.querydsl:querydsl-apt'
}

Repository 구성

// Custom Repository 인터페이스
public interface AnswerRepositoryCustom {
    Page<Answer> findAllWithQuestionAndUser(Pageable pageable);
}

// Custom Repository 구현
@RequiredArgsConstructor
public class AnswerRepositoryImpl implements AnswerRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Answer> findAllWithQuestionAndUser(Pageable pageable) {
        List<Answer> content = queryFactory
            .selectFrom(answer)
            .leftJoin(answer.question).fetchJoin()
            .leftJoin(answer.user).fetchJoin()
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        // count 쿼리
        Long total = queryFactory
            .select(answer.count())
            .from(answer)
            .fetchOne();

        return new PageImpl<>(content, pageable, total);
    }
}

// 메인 Repository
public interface AnswerRepository extends 
    JpaRepository<Answer, Long>, 
    AnswerRepositoryCustom {
}

Service에서 사용

@Service
@RequiredArgsConstructor
public class AnswerService {
    private final AnswerRepository answerRepository;

    public List<AnswerListResponse> getAnswerResponsePage(int page) {
        Pageable pageable = PageRequest.of(page, PAGE_SIZE);
        return answerRepository.findAllWithQuestionAndUser(pageable)
                .map(AnswerListResponse::from)
                .getContent();
    }
}

장단점 비교

Fetch Join

  • 장점:
    • 구현이 간단
    • 한 번의 쿼리로 필요한 데이터 조회
  • 단점:
    • 페이징 처리시 메모리 이슈 가능성
    • 여러 컬렉션을 함께 조회할 수 없음

QueryDSL

  • 장점:
    • 타입 안전성 보장
    • 동적 쿼리 생성 용이
    • 복잡한 조건과 조인 처리 가능
  • 단점:
    • 초기 설정 필요
    • 별도의 인터페이스와 구현체 작성 필요

참고사항

  1. 항상 즉시 로딩(EAGER)보다는 지연 로딩(LAZY)을 기본으로 설정
  2. 필요한 경우에만 fetch join을 사용
  3. 페이징이 필요한 경우 ToOne 관계만 fetch join하고, ToMany 관계는 batch size 조정 고려

이를 통해 N+1 문제를 효과적으로 해결하고 애플리케이션의 성능을 개선할 수 있습니다.

댓글