Spring Data JPA에서 N+1 문제, 진짜 해결하려면 이렇게 하세요
Spring Data JPA를 쓰면서 누구나 한 번쯤 겪는 N+1 문제를 실무에서 어떻게 찾아내고, JPQL fetch join과 EntityGraph로 효과적으로 해결하는지 구체적인 코드 예시와 함께 이야기합니다.
갑자기 DB 쿼리가 폭증하는 걸 어떻게 찾았냐고요?
"왜 갑자기 우리 서비스가 느려졌지?" 이런 고민 해본 적 있죠? 저도 그랬어요. 특히 Spring Data JPA 쓸 때, 한 엔티티를 조회하는데 수십, 수백 개 쿼리가 나가는 걸 보고 멘붕이 왔죠. 이게 바로 N+1 문제입니다.
실제로, 한 고객과 그 고객의 주문 목록을 조회한다고 가정해봅시다. 고객 100명을 조회하는데, 각 고객마다 주문을 따로 조회하면 총 1(고객 조회) + 100(주문 조회) = 101개의 쿼리가 나가요. 이게 쌓이면 DB 부하도 크고, 네트워크 왕복 비용도 커서 전체 응답 속도가 뚝 떨어집니다.
"LAZY" 로딩이 기본인데, 왜 N+1 문제가 생길까요?
Spring Data JPA에서 연관된 엔티티는 기본적으로 LAZY 로딩으로 설정되어 있어요. 즉, 실제로 필요할 때 데이터를 가져오죠. 이게 편리한데, 반대로 말하면 연관 엔티티를 하나씩 호출할 때마다 별도의 쿼리가 나갑니다.
예를 들어, @OneToMany(fetch = FetchType.LAZY)로 주문 목록을 설정했다면, 고객을 조회할 때 주문은 안 가져오고, 주문 목록에 접근하는 순간 쿼리가 나가요. 100명의 고객 각각 주문을 조회하면 100개의 쿼리가 추가되는 거죠. 이게 바로 N+1 문제의 핵심입니다.
쿼리 로그부터 꼭 확인하세요
이 문제를 잡으려면 우선 쿼리 로그를 켜야 합니다. Spring Boot에서는 application.properties에 다음 설정을 추가하면 SQL 로그를 볼 수 있어요.
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
이렇게 하면 실제 실행되는 쿼리가 콘솔에 찍히는데, 쿼리가 수십 개씩 반복되는 걸 발견하면 N+1 문제를 의심해야 합니다. 저도 처음엔 이 로그를 보고 "아, 이게 문제였구나" 깨달았죠.
fetch join으로 한 방에 쿼리 줄이기
가장 직관적인 해결책은 JPQL에서 fetch join을 써서 연관 엔티티를 한 번에 다 가져오는 겁니다. 예를 들어, 고객과 주문을 한꺼번에 조회하는 쿼리는 이렇게 생겼어요.
@Query("SELECT c FROM Customer c JOIN FETCH c.orders WHERE c.id = :id")
Optional<Customer> findByIdWithOrders(@Param("id") Long id);
이렇게 하면 고객과 주문을 한 번의 쿼리로 다 가져와서, N+1 문제를 확실히 막을 수 있습니다.
하지만 주의할 점은, fetch join을 너무 남발하면 조인 결과가 많아져서 메모리 부담이 커질 수 있다는 것! 특히 @OneToMany 컬렉션을 fetch join할 때는 페이징 쿼리가 어려워지니 상황에 맞게 써야 합니다.
EntityGraph는 상황에 따라 더 깔끔한 대안
JPQL을 직접 쓰기 귀찮거나, Repository 메서드 이름만으로 쿼리를 짜고 싶을 때는 EntityGraph 기능이 큰 도움이 됩니다.
@EntityGraph(attributePaths = {"orders"})
Optional<Customer> findById(Long id);
이렇게 하면 findById 메서드가 호출될 때 연관된 주문을 fetch 해서 N+1 문제를 방지해줍니다. JPQL보다 선언적이라 코드가 더 깔끔해지고, 필요한 연관관계만 골라서 가져올 수 있어요.
단점은 복잡한 조인 조건이나 필터링이 필요할 때는 JPQL이 더 유연하다는 점입니다.
실제로 우리 팀에서 이렇게 고쳤어요
우리 서비스에서 고객 리스트와 각 고객의 주문 요약을 보여주는 API가 있었는데, 호출할 때마다 DB 쿼리가 200개 넘게 나갔어요.
- 쿼리 로그를 켜서 문제 확인
- Repository에 fetch join JPQL 추가
- 단건 조회는 EntityGraph 적용
코드 예시는 이렇습니다.
@Repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {
@EntityGraph(attributePaths = {"orders"})
List<Customer> findAll();
@Query("SELECT c FROM Customer c JOIN FETCH c.orders WHERE c.id = :id")
Optional<Customer> findByIdWithOrders(@Param("id") Long id);
}
이후 쿼리 수가 200개에서 2개로 줄었고, 응답 속도도 3초에서 300ms로 대폭 개선됐습니다.
N+1 문제, 근본적으로는 설계 고민이 필요해요
fetch join이나 EntityGraph는 좋은 해결책이지만, 무조건 많이 쓰면 안 됩니다.
- 너무 많은 연관관계를 한꺼번에 fetch 하면 메모리 부담 증가
- 페이징 처리 시 fetch join은 복잡해짐
- 서비스 요구사항에 맞게 필요한 데이터만 선별해서 가져와야 함
그래서 저는 평소에 쿼리 로그를 자주 보고, 특정 API가 느려지면 바로 N+1 문제부터 의심하는 습관을 들였어요.
마무리하며
N+1 문제는 백엔드 성능 이슈 중 정말 흔하지만, 초기에 잡지 않으면 서비스 전체가 느려지는 주범입니다.
- 쿼리 로그로 문제를 조기 발견하고
- fetch join과 EntityGraph를 적절히 활용하며
- 설계 단계에서 연관관계와 조회 패턴을 고민하는 게 핵심입니다.
처음엔 "LAZY가 기본인데 왜 문제지?" 싶지만, 실제로 겪어보면 이게 얼마나 치명적인지 알게 됩니다.
여러분도 쿼리 로그부터 꼭 켜고, N+1 문제와 싸워보세요.
참고 자료
- Spring Boot Reference Documentation
- Spring Framework - Core Technologies
- Baeldung - Spring Boot Performance Tuning
운영에서 바로 점검할 항목 1
-
Spring Data JPA에서 N+1 문제는 기본적으로 연관 엔티티를 지연 로딩(LAZY)할 때 발생하며, 이를 해결하기 위해 fetch join을 활용한 JPQL 쿼리 튜닝이 효과적이다. (Spring Boot Reference Documentation) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
-
EntityGraph 기능을 사용하면 특정 조회 시점에 필요한 연관 엔티티를 명시적으로 fetch하여 N+1 문제를 방지할 수 있다. (Spring Framework - Core Technologies) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
-
성능 개선을 위해 쿼리 실행 로그를 통해 N+1 문제를 식별하고, 쿼리 수를 줄이는 것이 중요하며, Spring Data JPA에서는 @Query 어노테이션과 fetch join 조합으로 이를 해결할 수 있다. (Baeldung - Spring Boot Performance Tuning) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
-
N+1 문제는 백엔드 성능 저하의 주요 원인 중 하나로, 특히 대용량 데이터 조회 시 네트워크 비용과 DB 부하를 증가시키므로 조기 탐지 및 해결이 필수적이다. (Baeldung - Spring Boot Performance Tuning) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
추가로, 배포 전에는 성능과 안정성뿐 아니라 로그 품질까지 확인해야 합니다. 에러 로그가 충분히 구조화되어 있지 않으면 원인 분석 시간이 길어지고, 같은 장애가 반복될 가능성이 높아집니다. 배포 후 24시간 관찰 구간에서 경보 임계치를 임시로 강화해 두는 것도 실무에서 자주 쓰는 방법입니다.
운영에서 바로 점검할 항목 2
-
Spring Data JPA에서 N+1 문제는 기본적으로 연관 엔티티를 지연 로딩(LAZY)할 때 발생하며, 이를 해결하기 위해 fetch join을 활용한 JPQL 쿼리 튜닝이 효과적이다. (Spring Boot Reference Documentation) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
-
EntityGraph 기능을 사용하면 특정 조회 시점에 필요한 연관 엔티티를 명시적으로 fetch하여 N+1 문제를 방지할 수 있다. (Spring Framework - Core Technologies) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
-
성능 개선을 위해 쿼리 실행 로그를 통해 N+1 문제를 식별하고, 쿼리 수를 줄이는 것이 중요하며, Spring Data JPA에서는 @Query 어노테이션과 fetch join 조합으로 이를 해결할 수 있다. (Baeldung - Spring Boot Performance Tuning) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
-
N+1 문제는 백엔드 성능 저하의 주요 원인 중 하나로, 특히 대용량 데이터 조회 시 네트워크 비용과 DB 부하를 증가시키므로 조기 탐지 및 해결이 필수적이다. (Baeldung - Spring Boot Performance Tuning) 실제 적용에서는 트래픽 패턴, 장애 허용 범위, 팀의 온콜 역량을 같이 봐야 합니다. 초기에는 전체 전환보다 일부 기능에 먼저 도입하고, 지표가 안정화되는지 확인한 다음 확장하는 방식이 안전합니다. 특히 롤백 기준을 사전에 숫자로 정의해 두면 운영 중 의사결정 속도가 크게 좋아집니다.
추가로, 배포 전에는 성능과 안정성뿐 아니라 로그 품질까지 확인해야 합니다. 에러 로그가 충분히 구조화되어 있지 않으면 원인 분석 시간이 길어지고, 같은 장애가 반복될 가능성이 높아집니다. 배포 후 24시간 관찰 구간에서 경보 임계치를 임시로 강화해 두는 것도 실무에서 자주 쓰는 방법입니다.
Comments
이 글에 대한 경험이나 의견을 남겨보세요.
댓글 기능을 활성화하려면 Giscus 환경변수를 설정하세요.
README의 Giscus 설정 섹션에서 5분 안에 연결할 수 있습니다.