본문 바로가기
CS 먹고 레벨업~

귀하게 자란 내가 N+1 문제 같은 걸 봐도 될까?

by 봄설날 2025. 11. 4.

귀하게 자란 내가..

사실 'N+1 문제’는 워낙 유명하기 때문에 간단한 설명 뒤에 내 경험을 써보려고 한다. 이번 글은 일종의 복습이자 회고의 느낌이다.

우선 N+1 문제에 대해 살짝 보고 넘어가도록 하자.

ORM의 편리함 뒤에 숨겨진 함정

JPA나 Hibernate 같은 ORM은 개발자에게 반복적인 SQL 작성에서 벗어나 객체 지향적으로 데이터베이스를 다룰 수 있게 해주는 도구이다. findById(). findAll() 같은 메서드를 호출하는 것만으로 데이터를 객체로 변환시킬 수 있다.

 

하지만 이 편리함 뒤에는 N+1 문제라는 친구가 항상 따라온다. 오늘은 이 N+1 문제가 왜 발생하고 어떻게 해결할 수 있는지 알아보자.


N+1 문제 : 조회 한 번 했는데 쿼리가 100번

우선 너무나도 유명하면서 쉽게 이해할 수 있기 때문에 이전처럼 실습을 실제로 진행하지는 않았다.

 

블로그의 '게시글 목록'을 조회하는 기능을 만든다고 가정해 보자. 각 게시글(Post)은 작성자(User) 정보를 가지고 있다. (N : 1관계)

 

Entity

@Entity
public class Post {
    @Id
    @GeneratedValue
    private Long id;
    private String title;

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    @JoinColumn(name = "user_id")
    private User user;
    // ...
}

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    // ...
}

지연 로딩은 Post를 조회할 때 연관된 유저 정보는 그 데이터가 실제로 사용되지 전까지는 불러오지 않겠다는 설정이다. (성능상의 이점 때문에 기본적으로 사용)

 

Service

@Service
@Transactional(readOnly = true)
public class PostService {
    private final PostRepository postRepository;
    //생성자 주입

    public List<PostDto> findAllPosts() {
        List<Post> posts = postRepository.findAll(); //게시글 목록 조회
        return posts.stream()
                .map(post -> new PostDto(post.getTitle(), post.getUser().getName()))
                //작성자 이름 접근
                .collect(Collectors.toList());
    }
}

 

실행 : findAllPosts()를 호출하면 아래와 같은 쿼리가 실행된다.

 

1. 게시글 목록 조회 쿼리 (1번)

SELECT * FROM post;

 

2. 각 게시글의 작성자 조회 쿼리 (N번)

SELECT * FROM user WHERE id = ?; (첫 번째 게시글의 user_id)
SELECT * FROM user WHERE id = ?; (두 번째 게시글의 user_id)
(N개의 게시글만큼 반복)
SELECT * FROM user WHERE id = ?; (N 번째 게시글의 user_id)

 

결과적으로 게시글 목록을 조회하기 위해 총 1 + N 번의 쿼리가 실행된다.

게시글이 100개라면 101번의 쿼리가 DB로 날아가는 상황 : N+1 문제!


원인 분석

N+1 문제는 지연 로딩이 잘못된 방법이기 때문이 아니라 지연 로딩된 데이터를 사용하는 방식 때문에 발생한다.

  1. postRepository.findAll() 시점에는 Post 테이블만 조회한다. (쿼리 1번) User정보는 아직 필요 없다고 생각하여 가져오지 않는다(프록시 객체)
  2. .map(post -> ... post.getUser().getName())에서 실제로 User객체의 name에 접근하는 순간 JPA는 "아, 이제 진짜 User 정보가 필요하구나!"라고 판단한다.
  3. 하지만 이미 Post 목록을 가져온 상태이므로 각 Post객체마다 개별적으로 User정보를 다시 조회하는 쿼리를 N번 실행

해결책: Fetch Join으로 한 번에

이 문제를 해결하는 가장 효과적인 방법은 JPA의 Fetch Join을 사용하는 것이다.

  • Fetch Join이란?
  • JPA가 JPQL(Java Persistence Query Language)을 해석하여 SQL을 만들 때 연관된 엔티티까지 즉시 함께 조회하도록 SQL JOIN문을 만들어주는 기능

Repository

// PostRepository.java
public interface PostRepository extends JpaRepository<Post, Long> {

    // "Post를 조회할 때, 연관된 User 정보까지 JOIN해서 한번에 가져와!"
    @Query("SELECT p FROM Post p JOIN FETCH p.user")
    List<Post> findAllWithUser();
}

 

Service

// PostService.java
public List<PostDto> findAllPosts() {
    // findAll() 대신 새로 만든 메서드 호출
    List<Post> posts = postRepository.findAllWithUser();
    return posts.stream()
            .map(post -> new PostDto(post.getTitle(), post.getUser().getName()))
            // 이제 추가 쿼리 발생 X
            .collect(Collectors.toList());
}

 

실행 : 이제 findAllPosts()를 호출하면 단 한 번의 SQL 쿼리만 실행

SELECT p.*, u.*
FROM post p
INNER JOIN user u ON p.user_id = u.id;

 

JPA가 JOIN FETCH 키워드를 보고 Post와 User 테이블을 JOIN하는 SQL을 생성하여 처음부터 모든 필요한 데이터를 한 번에 가져왔기 때문에 N+1 문제가 해결

 

지금까지 N+1 문제가 무엇인지와 일반적인 해결 방법에 대해 알아봤다. 물론 BatchSize를 활용하는 등 다른 해결 방법도 있다.


 

프로젝트를 진행했을 때 한번은 외래키 중심의 설계를 통해 N+1 문제를 해결하고자 했던 적이 있다. 이 방법이 좋다는 것이 아니라 이러한 방법도 있으니 경험을 해보자는 취지로 팀원들과 상의해서 진행한 방법이다.

 

외래 키(FK) 값 자체를 엔티티의 필드로 직접 관리하고 JPA의 @ManyToOne, @OneToMany 같은 연관관계 매핑을 의도적으로 사용하지 않는 방식을 통해 N+1 문제를 원천적으로 방지하고 데이터 로딩 시점을 명확하게 제어하고자 하였다.

 

위의 예시와 같은 상황에서 이 방법을 사용해보자면 다음과 같다.

@ManyToOne으로 User 객체를 매핑하는 대신 Post 엔티티 안에 Long userId 와 같이 외래 키 값을 저장할 필드를 직접 선언하고 @Column(name = "user_id") 등으로 매핑한다.

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;

    @Column(name = "user_id") // User 객체 대신 userId 필드를 직접 관리
    private Long userId;
    // ...
    }

 

여기서 게시글 목록을 조회할 때는 Post 엔티티만 조회한다.

 

작성자 정보가 필요하면 조회된 Post 객체들에서 userId 목록을 추출하여 별도의 쿼리로 User 정보를 한 번에 조회합니다.

 

이 방식이 N+1 문제를 방지하는 이유는 간단하다. JPA의 연관관계 매핑 자체가 없으므로 지연 로딩이 발생할 여지가 차단되는 것이다. post.getUserId()를 호출해도 추가적인 쿼리가 발생하지 않으며 개발자가 명시적으로 User 정보를 조회하는 쿼리를 실행해야만 관련 데이터가 로드된다.


 

실제 프로젝트에서는 아래와 같이 사용했다. 진행했던 프로젝트는 공연 예약 서비스로 인터파크 티켓을 클론코딩하는 프로젝트다.

 

프로젝트를 기획하면서 각 공연은 여러 회차를 진행할 수 있도록 했다.

 

즉, 10/31일에 공연이 있다면 14:00 에 1회차 공연을, 20:00에 2회차 공연을 진행할 수 있도록 설계했다.

그러다보니 공연에 대한 기본 정보는 같지만 시작, 종료 시간이나 각 회차에 대한 티켓은 따로 관리해야했다.

때문에 공연 테이블(Performance)과 공연스케줄(PerformanceSchedule)을 따로 관리하기로 했다.

 

만약 일반적인 JPA 설계를 했다면 이랬을 것이다.

// Performance.java (공연)
@Entity
public class Performance {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    
    // ...
}

// PerformanceSchedule.java (공연 회차)
@Entity
public class PerformanceSchedule {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // '공연' 객체를 직접 참조
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "performance_id")
    private Performance performance;

    private LocalDateTime startTime;
    
    // ...
}

 

이렇게 되면 schedule.getPerformance().getTitle() 을 호출만 해도 바로 공연 제목을 가져올 수 있으니 편리하다.

하지만 N+1 이 발생한다.

 

유저가 공연을 예매하기 위해 전체 공연을 조회한다고 해보자. finaAll() 로 모든 공연 회차를 가져오고 거기서 다시 공연의 제목 등 정보에 접근하게 된다면

List<PerformanceSchedule> schedules = scheduleRepository.findAll();
for (PerformanceSchedule schedule : schedules) {
    String title = schedule.getPerformance().getTitle(); //추가 쿼리 발생
}
  1. 전체 스케줄 조회 → 쿼리 1번
  2. 각 스케줄마다 Performance 조회 → 쿼리 N번

총 1 + N번의 쿼리가 실행된다.

물론 여러 방법으로 해결할 수 있지만 쿼리를 작성할 때마다 N+1을 신경 써야 하고 실수가 발생할 수도 있다.

 

여기서 우리 팀이 약속한 방법으로 한다면 아래와 같다.

 

FK 중심 설계로 N+1 사전 차단하기

// Performance.java (공연)
@Entity
public class Performance {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    
    // ...
}

// PerformanceSchedule.java (공연 회차)
@Entity
public class PerformanceSchedule {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // @ManyToOne 매핑을 제거
    // FK 컬럼 자체를 필드로 가진다
    private Long performanceId; //객체가 아닌 ID를 직접 관리

    private LocalDateTime startTime;
    
    // ...
}

 

핵심은 FK 값 자체를 Long 타입 필드로 관리하는 것

위와 동일하게 공연을 예매하고 싶다고 가정해 보자.

/** 예약 가능한 전체 공연 검색
     *
     * @param pageable
     */
    @Query("""
        SELECT DISTINCT p
        FROM Performance p
        JOIN PerformanceSchedule ps ON p.id = ps.performanceId
        WHERE ps.remainingSeats > 0
        AND p.status = 'CONFIRMED'
        ORDER BY p.startDate DESC
    """)
    Page<Performance> findAvailablePerformances(Pageable pageable);

 

일반적인 JPA 연관 매핑을 사용했다면

FROM PerformanceSchedule ps JOIN ps.performance p WHERE ...

 

하지만 현재는

FROM Performance p JOIN PerformanceSchedule ps ON p.id = ps.performanceId

 

PerformanceSchedule에 private Long performanceId라는 단순 ID 필드만 있기 때문에 JPA의 객체 탐색(ps.performance)을 사용할 수 없다.

대신 SQL처럼 ON 절로 두 엔티티의 ID를 명시적으로 비교하는 방식을 사용한다.

장단점

장점:

  • N+1 문제 원천 차단
  • 필요한 데이터만 명확하게 조회
  • 쿼리 성능을 항상 예측 가능

단점:

  • 연관 객체 접근 시 Repository를 통해 별도 조회 필요
  • JPA의 편리한 객체 그래프 탐색 기능을 사용하지 못함

실제로 N+1 문제에 대해 신경을 쓸 필요 없이 안정적으로 서비스를 운영할 수 있었다.

하지만 불편한 것도 사실이다.

결론

이론적으론 상당히 효과있는 방법이다. 하지만 개인적으로는 Fetch Join이나 @BatchSize와 같은 JPA 자체의 최적화 기능들을 사용하는 것이 ORM의 편리함을 유지하면서도 N+1 문제를 해결할 수 있다고 생각했다.