N+1 문제 해결

조용현·2026년 3월 25일

문제 해결

목록 보기
5/13

배경

board 목록과 post 목록 조회 시 연관 엔티티(createdBy, author)를 DTO로 매핑하는 과정에서 지연 로딩이 발생하고 있었습니다. 목록 1회 조회 후 각 항목마다 추가 SELECT가 반복되는 전형적인 N+1 문제였습니다.


문제 원인

board 목록 조회 시 createdBy, post 목록 조회 시 author 연관 엔티티가 지연 로딩되어, 목록 크기(페이지당 20개)만큼 추가 SELECT가 반복 실행됐습니다.

Hibernate 로그로 실제 발생하는 쿼리 수를 확인했습니다.

조회 경로쿼리 내역합계
board 목록board 목록 1 + count 1 + createdBy N2 + N
post 목록board 1 + post 목록 1 + count 1 + likes 1 + author N4 + N

페이지당 20개 기준으로 board 목록은 22개, post 목록은 24개 쿼리가 실행됐습니다. (likes는 IN절 배치로 1개)

부하 테스트로 확인

실제 서비스와 유사한 복합 시나리오(읽기 90%, 쓰기 10%)로 부하 테스트를 진행했습니다.

N+1 해결 전 테스트 결과

popping_nplus1_mysql

지표수치
전체 평균 응답시간225ms
/boards/{slug} 평균 응답시간500ms
/boards/{slug} P951,136ms
처리량 (안정화 구간)84.0 RPS
MySQL QPS1.12K
MySQL Questions 최대1.41K
Peak Threads Runningmean 18 / max 29

/boards/{slug}(post 목록 포함)가 평균 500ms로 가장 느렸고, MySQL QPS가 1.12K에 달해 DB에 과도한 부하가 집중되고 있었습니다.


해결 과정

해결 방법 검토

N+1을 해결하는 방법으로 세 가지를 검토했습니다.

방법 1: Projection

필요한 컬럼만 SELECT해서 N+1 자체를 회피하는 방식입니다.

// Interface Projection
public interface PostSummary {
    Long getId();
    String getTitle();
    AuthorSummary getAuthor();

    interface AuthorSummary {
        String getNickname();
    }
}

목록 조회처럼 읽기 전용이고 필요한 필드가 제한적일 때는 Projection이 효율적인 선택지가 될 수 있지만, DTO/인터페이스를 별도로 관리해야 해 복잡도가 올라갑니다. 서비스 레이어에서 엔티티 전체를 다루는 로직이 섞여 있어 Projection으로 분리하기 어려운 구조여서 제외했습니다.

방법 2: @EntityGraph

기존 메서드에 선언만 추가하면 되어 코드가 단순합니다. 다만 이 프로젝트의 페이지네이션 조회에서는 @EntityGraph 사용 시 count 쿼리까지 함께 고려해야 했고, 원하는 형태로 JOIN을 세밀하게 통제하기 어렵다고 판단했습니다.

방법 3: fetch join + count 쿼리 분리

JPQL로 직접 fetch join을 작성하고 count 쿼리를 분리하는 방식입니다. @EntityGraph의 count 쿼리 문제를 해결할 수 있고, INNER JOIN FETCH / LEFT JOIN FETCH를 연관관계 nullable 여부에 따라 명시적으로 선택할 수 있습니다.

단건 조회: @EntityGraph / 페이지네이션: fetch join + count 쿼리 분리

단건 조회@EntityGraph를 사용했습니다. 별도 JPQL 없이 기존 메서드에 선언만 추가하면 되고, 단건이라 count 쿼리 문제도 없습니다.

페이지네이션은 fetch join + countQuery 분리를 적용했습니다. 이 프로젝트에서는 페이지네이션 구간에서 fetch join + countQuery 분리가 count 쿼리를 더 명시적으로 제어하기 쉬웠습니다. 그래서 @EntityGraph보다 이 방식이 더 적합하다고 판단했습니다. count는 개수만 세면 되므로 JOIN이 필요 없습니다.

// BoardRepository
@EntityGraph(attributePaths = "createdBy")
Optional<Board> findBySlug(String slug);                     // 단건: @EntityGraph

@Query(
    value = "SELECT b FROM Board b JOIN FETCH b.createdBy",
    countQuery = "SELECT COUNT(b) FROM Board b"              // count에서 JOIN 제거
)
Page<Board> findAll(Pageable pageable);                      // 페이지네이션: fetch join
// PostRepository
@EntityGraph(attributePaths = {"author", "board"})
Optional<Post> findById(Long id);                            // 단건: @EntityGraph

@Query(
    value = "SELECT p FROM Post p LEFT JOIN FETCH p.author WHERE p.board = :board",
    countQuery = "SELECT COUNT(p) FROM Post p WHERE p.board = :board"
)
Page<Post> findAllByBoard(@Param("board") Board board, Pageable pageable);

board의 createdBy는 항상 존재가 보장되므로 JOIN FETCH(INNER JOIN)를 사용했고, post의 author는 게스트 게시글의 경우 null이 될 수 있어 LEFT JOIN FETCH를 사용했습니다.

쿼리 수 변화

조회 경로해결 전해결 후감소율
board 목록 조회22개2개 (목록 + count)90.9% 감소
post 목록 조회23개3개 (목록 + count + board 조회)87.0% 감소
조회 경로쿼리 내역합계
board 목록board+createdBy JOIN 1 + count 12개
post 목록board+createdBy JOIN 1 + post+author LEFT JOIN 1 + count 1 + likes 14개

테스트 시나리오 설계

테스트 환경

  • EC2 t2.micro (1 vCPU, 953 MiB RAM), Docker 환경
  • MySQL 8, Spring Boot, HikariCP (pool size: 30)
  • JMeter + Prometheus + Grafana 모니터링

실제 사용자 패턴 반영

실제 커뮤니티 서비스의 트래픽 패턴은 읽기에 편향되어 있습니다.

1% 법칙: 90% 읽기(lurker) / 9% 소극적 참여(좋아요) / 1% 직접 작성

Thread Group역할스레드 수비율
TG1비로그인 읽기6060%
TG2로그인 읽기3030%
TG3비로그인 작성11%
TG4로그인 작성11%
TG5좋아요88%
  • Duration: 600s, Ramp-up: 60s
  • Think Time: GaussianRandomTimer (1000ms ± 500ms)

테스트 결과

popping_mysql

지표N+1 해결 전N+1 해결 후변화
전체 평균 응답시간225ms142ms37% 감소
/boards/{slug} 평균500ms270ms46% 감소
/boards/{slug} P951,136ms700ms38% 감소
처리량 (전체)78.2 RPS83.6 RPS+6.9%
처리량 (안정화 구간)84.0 RPS90.6 RPS+7.8%
MySQL QPS1.12K76332% 감소
MySQL Questions 최대1.41K90036% 감소
Peak Threads Runningmean 18 / max 29mean 11.5 / max 28감소
Sys Load859%791%감소
CPU94.1%94.2%유사

fetch join으로 N+1을 제거하자 요청당 쿼리 수가 대폭 줄어 DB 부하가 32% 감소했고, 응답시간이 안정화됐습니다. Peak Threads Running이 mean 18에서 11.5로 줄어든 것도 DB 대기 시간 감소를 반영합니다.

CPU가 해결 전후 모두 ~94%로 유사한 것은 N+1과 무관하게 1코어 서버 스펙 자체가 처리량의 상한이기 때문입니다. 처리량 개선 폭(+7.8%)이 응답시간 개선 폭(37%)보다 작은 이유도 여기에 있습니다.


트레이드오프

fetch join 도입으로 JPQL을 직접 작성하고 count 쿼리도 별도로 관리해야 합니다. 페이지네이션 조건이 추가되거나 정렬 기준이 바뀔 때마다 두 쿼리를 함께 수정해야 하는 부담이 생깁니다.

단건 조회에서 @EntityGraph를 유지했기 때문에 createdBy처럼 항상 존재가 보장되는 관계에서도 LEFT OUTER JOIN이 강제됩니다. 단건 조회에서 성능 차이가 미미하고 코드 단순성을 유지하기 위해 감수했습니다.

목록 조회에서 실제로 필요한 컬럼은 일부지만, fetch join은 엔티티 전체를 SELECT합니다. Projection을 적용하면 불필요한 컬럼 전송을 줄일 수 있으나, 서비스 레이어에서 엔티티 전체를 다루는 구조여서 이번에는 적용하지 않았습니다.

N+1 제거는 DB 부하 완화와 응답시간 안정화 측면에서 효과가 명확했으며, 서버 스펙 업그레이드 없이 소프트웨어 수준에서 달성할 수 있는 최적화였습니다.

profile
백엔드 개발자

0개의 댓글