N+1 문제란 무엇인가?
먼저 N+1 문제가 잘 드러나는 예시로 사용할 엔티티 코드를 살펴보겠습니다:
@Getter
@Setter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
private Integer depth;
// 🔽 셀프 1:N 관계 (하위 구성원들)
@OneToMany
@Fetch(FetchMode.SUBSELECT)
@JoinTable(
name = "user_subordinates",
joinColumns = @JoinColumn(name = "manager_id"),
inverseJoinColumns = @JoinColumn(name = "subordinate_id")
)
private List<User> subordinates;
// 🔽 셀프 N:1 관계 (상위 유저)
@ManyToOne
@JoinTable(
name = "user_subordinates",
joinColumns = @JoinColumn(name = "subordinate_id"),
inverseJoinColumns = @JoinColumn(name = "manager_id")
)
private User manager;
}
N+1 문제는 ORM(Object-Relational Mapping) 프레임워크, 특히 JPA(Java Persistence API)를 사용할 때 자주 발생하는 성능 이슈 중 하나입니다. 이 문제는 연관된 엔티티를 조회할 때, 예상보다 훨씬 많은 쿼리가 실행되어 데이터베이스 성능을 저하시킬 수 있습니다.
간단한 예를 들어 보겠습니다. 만약 조직의 구성원을 나타내는 User 엔티티가 있고, 이 User가 다른 여러 하위 구성원(subordinates)을 가질 수 있는 셀프 1:N 관계로 매핑되어 있다고 가정합시다. 그리고 관리자 역할을 가진 User 10명을 조회한 후, 각 관리자에 속한 하위 구성원들을 접근할 경우 다음과 같은 일이 벌어집니다:
- 먼저 관리자 10명을 가져오기 위한 1개의 SELECT 쿼리가 실행됩니다.
- 그 다음, 각 관리자마다 하위 구성원들을 조회하는 쿼리가 각각 실행됩니다. (총 10번)
결국 총 11번의 쿼리가 실행되는데, 이를 N+1 문제라고 부릅니다. 여기서 "N"은 조회된 관리자 수(10), "1"은 관리자 리스트를 조회한 첫 쿼리를 의미합니다.
이 문제는 데이터 양이 많아질수록 쿼리 수도 비례해서 많아지므로, 성능에 심각한 영향을 줄 수 있습니다. 개발 초기에는 잘 드러나지 않지만, 실서비스에서는 심각한 병목이 되는 경우가 많습니다.
N+1 문제 감지 방법
N+1 문제는 겉으로 드러나지 않지만, 아래와 같은 방법들을 통해 쉽게 감지할 수 있습니다:
1. Hibernate SQL 로그 확인
application.yml 또는 application.properties 파일에 아래 설정을 추가하면 실행되는 SQL 쿼리를 확인할 수 있습니다.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
이 설정을 통해 실제로 어떤 쿼리들이 실행되는지를 로그에서 확인할 수 있으며, 특정 조회 로직에서 반복적으로 유사한 SELECT 쿼리가 다수 실행된다면 N+1 문제가 발생하고 있는 것입니다.
2. Hibernate Statistics 사용
Hibernate의 SessionFactory에서 제공하는 통계를 통해 엔티티 로딩 횟수, 쿼리 수 등을 확인할 수 있습니다. 테스트 환경에서 유용하게 사용할 수 있는 방법입니다.
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.setStatisticsEnabled(true);
System.out.println("쿼리 실행 수: " + stats.getQueryExecutionCount());
3. p6spy를 활용한 쿼리 분석
p6spy는 JDBC 드라이버를 감싸서 애플리케이션에서 실행되는 모든 SQL 쿼리를 로그로 출력해주는 라이브러리입니다. 설치와 설정이 간단하고 실시간으로 SQL을 모니터링할 수 있어, N+1 문제를 포함한 다양한 성능 이슈를 빠르게 파악하는 데 유용합니다.
설정 방법 (Gradle 기준)
dependencies {
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.0'
}
application.yml 설정 예시
spring:
datasource:
url: jdbc:p6spy:mysql://localhost:3306/your_db
username: user
password: pass
jpa:
properties:
hibernate:
format_sql: true
logging:
level:
p6spy: DEBUG
p6spy는 다음과 같은 이점을 제공합니다:
- 실행된 쿼리를 실시간으로 로그에 출력
- 실제 바인딩된 파라미터까지 확인 가능 (JPA에서 자주 생략되는 부분)
- 콘솔이나 외부 로그 수집 시스템에서 쉽게 모니터링 가능
쿼리 로그를 통해 특정 요청에서 반복적으로 유사한 SELECT 쿼리가 다수 실행되는지 쉽게 확인할 수 있으며, 이로 인해 발생하는 N+1 문제를 빠르게 진단할 수 있습니다.
4. 기타 도구
- Spring Boot DevTools나 Actuator를 통해 요청 시간과 리소스 사용량을 추적할 수 있습니다.
- datasource-proxy, JPA Buddy 같은 도구들도 쿼리 분석에 도움이 될 수 있습니다.
이러한 도구들을 통해 애플리케이션의 특정 엔드포인트나 서비스 메소드에서 과도한 쿼리가 발생하는 지점을 파악하고, 그 원인을 분석해보는 것이 중요합니다.
즉시 로딩(EAGER) vs 지연 로딩(LAZY)
JPA에서는 연관 관계가 있는 엔티티를 조회할 때 데이터를 언제 불러올지를 설정할 수 있습니다. 이를 로딩 전략(Loading Strategy)이라고 하며, 크게 두 가지 방식이 존재합니다:
즉시 로딩(EAGER)
즉시 로딩은 연관된 엔티티를 즉시 같이 조회하는 방식입니다. 즉, 부모 엔티티를 조회할 때 관련된 모든 자식 엔티티를 한 번에 조회하게 됩니다.
@OneToMany(fetch = FetchType.EAGER)
private List<User> subordinates;
이 방식은 연관 데이터를 무조건 함께 가져오기 때문에, 실제로 사용하지 않더라도 쿼리가 수행됩니다. 또한 연관된 데이터가 많을 경우, JPA가 내부적으로 조인 또는 별도 쿼리를 여러 번 실행하게 되면서 N+1 문제가 발생할 수 있습니다.
지연 로딩(LAZY)
지연 로딩은 연관된 엔티티를 실제로 접근할 때까지 조회를 지연하는 방식입니다. 즉, 부모 엔티티를 먼저 가져오고, 자식 엔티티는 그 필드가 실제로 사용될 때 추가 쿼리로 가져옵니다.
@OneToMany(fetch = FetchType.LAZY)
private List<User> subordinates;
지연 로딩은 불필요한 쿼리 실행을 줄일 수 있지만, 연관 엔티티를 반복적으로 사용할 경우 N+1 문제가 발생할 수 있습니다. 예를 들어 List<User> users = userRepository.findAll()
이후에 루프 내에서 user.getSubordinates()
를 호출하면, 반복적으로 쿼리가 발생하게 됩니다.
트리 구조 예시와 출력 비교
다음과 같은 트리 구조가 있다고 가정해 봅시다:
User 1
├── User 2
│ └── User 4
└── User 3
└── User 5
아래 코드는 루트 유저(1번)를 기준으로 하위 구성원들을 순회하는 예시입니다:
User user = userRepository.findById(1L).orElse(null);
System.out.println("User: " + user.getName());
for (User sub : user.getSubordinates()) {
System.out.println(" Sub: " + sub.getName());
for (User sub2 : sub.getSubordinates()) {
System.out.println(" Sub2: " + sub2.getName());
}
}
[즉시 로딩일 경우 실행되는 쿼리 예시]
select ... from slow_users ... where su1_0.id = 1;
select ... from slow_users ... where su1_0.id = 2;
select ... from slow_users ... where su1_0.id = 4;
select ... from slow_users ... where su1_0.id = 3;
select ... from slow_users ... where su1_0.id = 5;
※ 동일한 형태의 복잡한 조인 쿼리가 여러 번 반복 실행됩니다.
출력 예시
User: 사용자1
Sub: 사용자2
Sub2: 사용자4
Sub: 사용자3
Sub2: 사용자5
[지연 로딩일 경우 실행되는 쿼리 예시]
select ... from slow_users ... where su1_0.id = 1;
select ... from slow_user_subordinates ... where s1_0.manager_id = 1;
select ... from slow_user_subordinates ... where s1_0.manager_id = 2;
select ... from slow_user_subordinates ... where s1_0.manager_id = 3;
※ 루트 노드에 대한 조회 1회 + 하위 노드 접근 시점에만 쿼리가 개별 실행됩니다.
- 기본적으로는 지연 로딩(LAZY) 을 사용하는 것이 권장됩니다.
- 즉시 로딩은 정말 모든 연관 데이터를 항상 함께 사용해야 할 경우에만 제한적으로 사용해야 합니다.
Fetch Join을 활용한 N+1 문제 해결
N+1 문제를 해결하는 가장 강력한 방법 중 하나는 fetch join을 사용하는 것입니다. fetch join은 JPQL에서 연관된 엔티티를 함께 조회하는 방식으로, 실제 SQL 조인처럼 작동하여 쿼리 수를 획기적으로 줄여줍니다.
Fetch Join 문법
@Query("SELECT u FROM User u JOIN FETCH u.subordinates WHERE u.id = :id")
User findUserWithSubordinates(@Param("id") Long id);
위 쿼리는 지정된 ID의 사용자와 그 하위 구성원들을 한 번의 쿼리로 가져옵니다. 이를 통해 기존에는 user.getSubordinates()
호출 시마다 쿼리가 발생하던 부분이 사라집니다.
Fetch Join 쿼리 예시
select u.id, u.name, s.id, s.name
from users u
join user_subordinates us on u.id = us.manager_id
join users s on s.id = us.subordinate_id
where u.id = 1;
Fetch Join의 주의사항
- 컬렉션 페치 조인은 1대N 관계에서 페이징과 함께 사용하면 안 됩니다. JPA는 메모리에서 페이징을 수행하기 때문에 성능 이슈와 결과 왜곡이 발생할 수 있습니다.
- 중복 데이터 제거를 위해
DISTINCT
키워드를 사용할 수 있습니다. JPA에서는 결과 객체 기준으로 중복을 제거합니다.
@Query("SELECT DISTINCT u FROM User u JOIN FETCH u.subordinates")
List<User> findAllWithSubordinates();
fetch join은 단순히 성능 향상 뿐만 아니라, 애플리케이션의 데이터 접근 방식 자체를 명확히 하고 예측 가능하게 만들어줍니다.
- 필요할 때만 fetch join이나 @EntityGraph를 통해 명시적으로 연관 데이터를 로딩하는 것이 좋습니다.
@EntityGraph를 이용한 N+1 문제 해결
@EntityGraph
는 JPA에서 제공하는 어노테이션으로, JPQL이나 메서드 이름 기반 쿼리를 사용할 때 연관된 엔티티를 명시적으로 로딩(fetch) 하도록 지정할 수 있습니다. 내부적으로는 fetch join과 유사하게 작동하지만, 쿼리문과 로딩 전략을 분리할 수 있다는 점에서 유지보수성과 가독성이 뛰어납니다.
@EntityGraph 사용 예시
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = {"subordinates"})
List<User> findByDepth(Integer depth);
}
위 예시는 findByDepth()
메서드를 호출하면, depth
조건에 해당하는 사용자들과 그 하위 구성원(subordinates
)까지 함께 로딩되도록 합니다. 이 방식은 fetch join을 직접 쓰지 않고도, 연관 데이터를 한 번에 불러올 수 있는 매우 깔끔한 방법입니다.
동작 방식
Spring Data JPA는 @EntityGraph
가 선언된 메서드 실행 시, JPQL을 자동 생성할 때 내부적으로 fetch join을 포함한 쿼리를 생성합니다.
결과적으로 실행되는 쿼리 예시
select distinct u1_0.id, u1_0.name, s1_1.id, s1_1.name
from users u1_0
left join user_subordinates s1_0 on u1_0.id = s1_0.manager_id
left join users s1_1 on s1_1.id = s1_0.subordinate_id
where u1_0.depth = ?
@EntityGraph의 장점
- 코드가 간결하고 쿼리문과 로딩 전략이 분리됨
- 메서드 이름 기반 쿼리에서도 연관 로딩 지정 가능
- 여러 연관 필드를 동시에 명시 가능 (예:
attributePaths = {"subordinates", "manager"}
)
이처럼 @EntityGraph
는 복잡한 쿼리를 작성하지 않고도 N+1 문제를 효과적으로 피할 수 있는 실용적인 도구입니다.
페이징 처리 시 주의사항
fetch join
이나 @EntityGraph
는 N+1 문제 해결에 매우 유용하지만, 페이징 처리와 함께 사용할 경우 주의가 필요합니다.
JPA에서는 컬렉션(1**:N**** 관계)에 fetch join을 적용하면서 페이징을 함께 수행하면, 메모리 상에서 페이징 처리를 하기 때문에 결과가 왜곡되거나 성능이 저하될 수 있습니다.**
예를 들어 다음과 같은 코드:
@Query("SELECT u FROM User u JOIN FETCH u.subordinates")
Page<User> findPagedUsersWithSubs(Pageable pageable); // 잘못된 사용
이 코드는 예상대로 작동하지 않으며, 다음과 같은 문제가 발생할 수 있습니다:
- 중복된 User 객체로 인해 실제 페이지 크기보다 더 적은 결과가 반환됨
- 전체 결과를 한 번에 가져와 메모리에서 잘라내므로 OutOfMemoryError 가능성 존재
해결 방법
- 1:N 관계는 fetch join을 사용하지 않고, 엔티티 ID만 기준으로 우선 페이징 후에 후처리 방식으로 서브 쿼리 조회를 수행합니다.
- 또는 Batch Size 설정을 통해 컬렉션 로딩 시 in 절을 활용한 쿼리로 효율적 로딩이 가능하도록 설정합니다.
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 100
이 설정은 @OneToMany
, @ManyToOne
, @OneToOne
, @ManyToMany
등 모든 지연 로딩 관계에 대해 일괄 로딩(batch fetching)을 지원합니다.
페이징이 필요한 화면에서는 무조건 fetch join이나 EntityGraph를 사용하는 것이 능사는 아니며, 상황에 따라 전략을 달리해야 합니다.
@Fetch(FetchMode.SUBSELECT)를 이용한 N+1 문제 해결
JPA에서 N+1 문제를 해결하는 또 다른 방법으로는 Hibernate 고유의 기능인 @Fetch(FetchMode.SUBSELECT)
전략이 있습니다. 이 전략은 지연 로딩(LAZY)을 유지하면서도, 컬렉션 로딩 시 한 번의 서브 쿼리로 모든 데이터를 가져오는 방식입니다.
사용 예시
@OneToMany(fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<User> subordinates;
이 설정을 사용하면, 처음에는 상위 엔티티(User)만 조회되고, getSubordinates()
를 호출하는 시점에서 서브 쿼리를 통해 전체 컬렉션이 한 번에 로딩됩니다.
실행되는 SQL 예시
select * from users where depth = 1;
-- 이후 모든 상위 유저 ID를 in 절로 조회
select *
from user_subordinates us
join users u on u.id = us.subordinate_id
where us.manager_id in (1, 2, 3, ...);
fetch join vs SUBSELECT 비교
항목 | fetch join | @Fetch(FetchMode.SUBSELECT) |
---|---|---|
쿼리 수 | 1번 (join) | 2번 (원본 + 서브쿼리) |
성능 | 매우 빠름 (단, 중복 가능성 있음) | 컬렉션 수에 따라 유리할 수 있음 |
페이징과의 호환성 | 불가 (1:N fetch join + 페이징) | 가능 (페이징 후 LAZY 로딩 시) |
사용 추천 상황 | 소규모 데이터, 전체 조회 | 컬렉션이 많은 경우, 페이징과 함께 |
정리
- @Fetch(FetchMode.SUBSELECT)는 지연 로딩의 장점을 살리면서도 N+1 문제를 회피할 수 있는 전략입니다.
- 특히 페이징과 함께 사용할 경우, fetch join이나 EntityGraph 대신 좋은 대안이 될 수 있습니다.
- 다만, Hibernate 전용 기능이므로 JPA 표준이 아니라는 점은 고려해야 합니다.
마무리 요약 및 결론
N+1 문제는 개발 초기에 잘 눈에 띄지 않지만, 실서비스에서 트래픽이 증가하고 데이터가 많아질수록 심각한 병목 요소로 작용할 수 있습니다. JPA를 사용하는 개발자라면 반드시 이 문제를 이해하고 사전에 방지할 수 있는 전략을 갖추는 것이 중요합니다.
이번 글에서는 다음과 같은 내용을 살펴보았습니다:
- N+1 문제의 개념과 발생 원인
- Hibernate SQL 로그, p6spy, Statistics 등을 활용한 문제 진단 방법
- 즉시 로딩 vs 지연 로딩의 차이와 트리 구조 예시를 통한 이해
- fetch join, @EntityGraph, @Fetch(FetchMode.SUBSELECT)를 활용한 해결 전략
- 페이징과 연관 로딩 전략이 충돌하는 문제와 그 대안 (Batch Size 등)
각 방법은 상황에 따라 장단점이 있으며, 일반적으로는 다음과 같은 기준으로 선택할 수 있습니다:
상황 | 추천 전략 |
---|---|
단건 조회 + 연관 데이터가 반드시 필요한 경우 | fetch join or @EntityGraph |
페이징이 필요한 경우 | 지연 로딩 + Batch Fetching or SUBSELECT |
연관 데이터가 매우 많은 대규모 트리 구조 | SUBSELECT or DTO 분리 조회 |
JPA는 강력한 도구이지만, 잘못 사용하면 오히려 성능을 해치는 결과를 낳을 수 있습니다. 쿼리 로그를 자주 확인하고, 로딩 전략을 명시적으로 설계하며, 반복되는 쿼리에 민감하게 반응하는 습관이 중요합니다.
이 글이 스프링 부트와 JPA를 사용하는 개발자 여러분께 실질적인 도움이 되길 바랍니다.
'웹 (Web) 개발' 카테고리의 다른 글
Redis 관리 도구 비교 및 RedisInsight 설정 가이드 (1) | 2025.04.09 |
---|---|
Redis Cluster vs Sentinel 비교 및 Sentinel 적용 가이드 (0) | 2025.04.08 |
Vite: 현대적인 프론트엔드 빌드 도구의 진화 (0) | 2024.10.21 |
React: 모던 웹 개발의 필수 도구 (1) | 2024.10.18 |
PyCharm vs VSCode: 최고의 IDE는 무엇일까? (2) | 2024.10.18 |