우당탕탕

JPA N+1 문제 실제 겪고 단계별로 해결한 경험담 본문

Tech/Spring

JPA N+1 문제 실제 겪고 단계별로 해결한 경험담

모찌모찝 2026. 6. 20. 09:43

JPA로 개발하면서 가장 골치 아팠던 게 바로 N+1 문제</strong였어요. 저도 처음엔 왜 이렇게 데이터베이스 쿼리가 엄청나게 많이 나가는지 감이 안 잡혔거든요. 이걸 해결하는 과정에서 삽질도 꽤 했고, 방법도 여러 가지 써보다가 결국에 깔끔하게 해결할 수 있었어요.

이번 글에서는 제가 직접 겪은 JPA N+1 문제의 발생 상황부터, 문제 진단 방법, 그리고 단계별 해결 절차를 아주 상세하게 풀어봤어요. 코드를 중심으로 꼭 따라 할 수 있게 안내할게요.

개발 환경 / 버전 정보

저는 Java 17 기반으로 개발했고, Spring Boot 3.1 버전을 사용했습니다. JPA는 기본적으로 hibernate-core 6.x 버전을 이용했고요. 데이터베이스는 MySQL 8입니다.

N+1 문제 실제 발생 상황과 처음 진단한 방법

사실 이 부분이 가장 골치 아팠어요. 간단한 게시판 프로젝트를 만들었는데, 게시글 목록을 띄우는 화면에서 유독 페이지 로딩이 느린 거예요. 원인을 찾아보니 쿼리 로그가 정말 많더라고요.

우선 application.properties에 아래 설정을 넣어서 쿼리 로그를 확인했어요.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

그랬더니 게시글 10개를 가져오는 쿼리가 10번 반복되는 걸 확인했죠. 즉, 게시판 글 목록을 가져오는 쿼리 1번 + 각 게시글마다 작성자를 가져오는 쿼리 10번 이렇게 총 11번의 쿼리가 나가는 상황이었어요. 이게 바로 N+1 문제였어요.

N+1 문제가 발생한 코드 구조와 현상

문제를 일으킨 코드는 게시글과 작성자(User) 엔티티의 관계 설정 부분이었는데요, 대략 이렇게 돼 있었어요.

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User author;

    // getters, setters
}

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // getters, setters
}

게시글 목록을 가져올 때, 저는 아래처럼 Repository에서 단순히 findAll()을 호출했습니다.

List posts = postRepository.findAll();

그런데 프론트에서 각 게시글의 작성자 이름을 보여주려 하니, LAZY로 설정된 author 필드가 실제로 접근될 때마다 추가 쿼리가 발생하는 거였죠. 이게 N+1 문제의 전형적인 징후였어요.

N+1 문제 단계별 해결 절차

저는 아래 3단계를 순서대로 진행했어요.

  • 1. 문제 상황 재현 및 쿼리 로그 확인
  • 2. Fetch 전략 변경 및 Join Fetch 적용
  • 3. 필요시 DTO 직접 조회로 최적화

1단계: 문제 상황 재현 및 쿼리 로그 확인

위에서 이미 설명했듯이 쿼리 로그를 켜서 실제 N+1 문제가 발생하는지 확인하는 게 제일 먼저 했던 일이었어요.

로그 설정을 마친 후에 API를 호출해보고, 쿼리 수가 게시글 수 +1 인지 꼭 확인하세요.

2단계: Fetch 전략 변경 및 Join Fetch 적용

여기서 많이들 틀리는 부분이 기본 FetchType 때문에 애매한 동작이 나는 건데요, 저는 원래 LAZY 설정을 유지하면서 쿼리를 줄이려고 했어요. 이를 위해 JPQL에 join fetch 구문을 사용했습니다.

Repository에 다음 메서드를 추가했죠.

@Query("select p from Post p join fetch p.author")
List findAllWithAuthor();

이 메서드를 쓰니, 처음 게시글을 조회할 때 작성자 정보까지 한 번에 가져와서 쿼리 횟수가 확 줄었어요. 쿼리 로그를 찍어보니 딱 1번만 데이터베이스에 요청이 가더라고요.

3단계: DTO 직접 조회로 최적화

근데 여기서 한 가지 문제는, 엔티티를 통째로 가져오면 불필요한 데이터까지 같이 조회될 수 있다는 점이에요. 저는 실제로 게시글 제목과 작성자 이름만 화면에 보여주고 싶었거든요.

그래서 DTO를 직접 조회하는 방법을 선택했어요. 이건 쿼리 결과를 바로 DTO로 매핑해서 필요한 필드만 가져오는 거예요.

Repository에 이렇게 메서드를 만들었어요.

public class PostSummaryDto {
    private final String title;
    private final String authorName;

    public PostSummaryDto(String title, String authorName) {
        this.title = title;
        this.authorName = authorName;
    }

    // getters
}

@Query("select new com.example.dto.PostSummaryDto(p.title, a.name) from Post p join p.author a")
List findPostSummaries();

이렇게 하니까 필요한 데이터만 딱 조회되고, 쿼리도 1번만 나가서 성능이 훨씬 좋아졌어요.

여기서 삽질했던 부분들

이 에러가 왜 나는지 한참 찾았는데, 바로 페치 조인과 컬렉션 조합 문제였어요. 처음에 컬렉션 조인을 쓰면서, Hibernate에서 경고가 뜨고 쿼리 결과가 중복돼서 난감했었거든요.

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

이 문제는 컬렉션 페치 조인을 여러 번 하면 발생하는데, 저는 조회를 단순화해서 우선 단일 연관관계만 join fetch로 해결했어요. 컬렉션 관계는 나중에 따로 최적화하는 걸로 방향을 바꿨죠.

심화: 이것도 알면 좋아요

한 가지 팁이 있다면, @EntityGraph 애노테이션을 써서도 N+1 문제를 줄일 수 있다는 점이에요. JPQL을 쓰기 싫을 때 깔끔하게 쓸 수 있거든요.

예를 들어 아래처럼 Repository 메서드에 추가합니다.

@EntityGraph(attributePaths = {"author"})
List findAll();

이렇게 하면 기본 findAll 호출 시점에 author 필드가 즉시 로딩되면서 N+1 쿼리가 줄어들어요. 실제로 저도 간단할 때 이 방법 자주 쓰고 있습니다.

자주 물어보시는 것들

Q. LAZY 대신 EAGER로 바꾸면 N+1 문제가 해결되나요?

A. 한눈에 보기엔 그렇지만, EAGER는 연관된 모든 엔티티를 무조건 한꺼번에 조회해서 오히려 성능 저하나 예상치 못한 쿼리가 나갈 수도 있어요. 제 경험상 LAZY를 기본으로 두고 필요할 때 조인 페치를 쓰는 게 훨씬 안전합니다.

Q. @BatchSize를 쓰면 어떻게 달라지나요?

A. @BatchSize는 지연 로딩 시 쿼리 호출 시 몇 개 단위로 묶어서 조회하는 방법이에요. 단발성 쿼리 호출 횟수는 줄지만 완전히 N+1 문제를 해결하지는 못해서 주로 보조 수단으로 씁니다.

제가 직접 겪고 고친 과정을 보면, JPA의 N+1 문제는 그냥 막연히 LAZY가 문제라고만 생각하면 안 되고 꼭 쿼리 로그를 보면서 어떤 쿼리가 얼마나 나가는지 확인하면서 한 단계씩 접근하는 게 중요하다는 걸 알 수 있어요. 이렇게 단계별로 점검하고 조인 페치, DTO 조회, 엔티티 그래프 같은 여러 방법을 적절히 조합하면 해결할 수 있습니다.

다음 프로젝트에도 이 경험이 큰 도움이 될 것 같아서 공유합니다.

Comments