-
N+1 문제와 Fetch joinBack-end Developer/Server, Spring 2025. 9. 9. 01:46
N+1 문제란 무엇인가?
ORM에서 연관된 데이터를 조회할 때 1번의 쿼리로 가져올 수 있는 데이터를 N개의 쿼리로 가져오게 되어 성능 저하를 일으키는 문제입니다.
1:N 연관 관계에서 1에 해당하는 테이블(A) 데이터를 N의 테이블(N) 기준으로 조회시 DB에서 쿼리를 실행하는 것과 다르게 프로그램 내에서 A에 연관된 B의 데이터도 모두 함께 조회되는 것을 의미합니다.
더보기B테이블에서 A에 해당하는 데이터를 조회하려 할 때, A에 해당하는 데이터만 가져오는 것이 아닌 A에 연관된 B의 데이터 모두를 조회하는 문제
Query: SELECT * FROM B WHERE A.id=1?;
즉, 1의 해당하는 데이터 10개 조회 했을때, 10 * N 개의 쿼리가 추가로 발생하게 됩니다. 이에 따라 실제로 조회하려던 데이터 수와는 다르게 엄청난 성능 저하를 유발할 수 있습니다.
어떻게 해결 할 수 있을까?
N+1 자체가 발생하는 이유는 한쪽 테이블만 조회하고 연결된 다른 테이블은 다시 조회하기 위해 쿼리를 한 번더 발생시키기 때문입니다. 따라서, 미리 두 테이블을 JOIN 하여 한 번에 모든 데이터를 가져올 수 있다면 N+1 문제를 해결할 수 있습니다.
이를 위해 Fetch join을 사용합니다. JPA 자체로는 사용할 수 없어서 내부에서는 유사 문제를 QueryDSL로 쿼리를 구현해 문제를 해결했습니다. (일반적으로 JPQL?, Native query? 등을 사용하면 될 것 같습니다.)
Fetch Join 동작 방식
특정 테이블 조회시 연관 관계의 데이터를 inner join으로 함께 묶어 가져옵니다. 사실상 Eager와 같은 방식이 아닐까 싶습니다. 따라서 사용하지 않는 데이터가 함께 조회되지만, 조회 쿼리는 1회만 발생하기 때문에 성능을 개선할 수 있습니다.
설정 자체를 Eager로 하지 않는 이유는 매번 데이터를 한번에 조회해 오는 것 또한 리소스 낭비기 때문입니다. 필요한 시점에만 fetch 를 사용하고, 이외에는 Lazy loading을 통해 접근하는 것이 가장 효율적이라고 생각됩니다.
실제 사례
N + 1 에 대해서 이야기하는게 맞겠으나, 유사한 실제 사례에 대해 소개하려합니다. (이미 N + 1에 대한 실제 사례는 인터넷에 너무 많기 때문에..)
올해 초 작업을하면서 유사한 문제에 직면했었는데, 직관적으로 어떤 문제인지 알기 어려웠습니다.
구독 결제를 관리하기 위한 데이터 구조였고 결제 테이블에 간편 결제 테이블 데이터와 캐시 결제 테이블 데이터가 각각 OneToOne양방향으로 묶여있었습니다.
payments 1 ------------ ------------ 1 simple_payments 1 ------------ ------------ 1 cash_payments 이 부분에서 한가지 허점이 존재했는데, OneToOne의 LazyLoading 입니다.
더보기지연 로딩이란?
EntityManager가 Entity 로드시 실제 객체 대신 proxy 객체를 로드하는 것을 의미합니다. 실제 객체가 필요한 시점까지는 proxy 객체를 로딩하게되며, 이후 실제 객체가 필요한 시점에 다시 실제 객체를 가져옵니다. 또한 한 번 실제 객체가 로딩되면 이후엔 계속 실제 객체를 통해 데이터를 로딩합니다.
실제 객체가 필요한 시점이란? 테이블의 데이터를 getXXX 등으로 호출하게되면 실제 데이터를 받아 쓰는 것인데, 이 때 프록시가 아닌 DB 조회를 통해 데이터를 가져오게 됩니다.
당연히 실제 객체가 필요할 때 로딩하기 위해 fetchType: Lazy로 설정하는 것이 일반적이지만, 그 설정이 작동하지 않았습니다.
그 이유는 Hibernate의 동작 특징에 있었습니다.
Hibernate 내에서는 OneToOne 관계에서 proxy가 아닌 실제 객체이거나 null 임을 보장해야합니다. 즉, payments 테이블의 데이터를 조회하는 시점에 simple_payments 또는 cash_payments의 데이터 존재 여부를 보장하기 위한 쿼리가 1회 더 발생하게 됩니다.
이를 쿼리상에 fetchJoin을 한번 적용함으로써, payments 조회 1회 / 하위 테이블 데이터 확인 여부 1회 총 2회의 쿼리를 1회로 조인을 통해 데이터를 가져오는 방식으로 데이터 존재 여부 쿼리를 생략하는 효과로 개선되었습니다.
(단순히 1회긴 하나, 해당 테이블의 데이터는 일 약 60만건의 데이터가 발생하는 테이블로 테스트 시에도 40만건의 데이터로 진행했기 때문에 실제 예상했던 처리 시간보다 훨씬 오래 걸리는 문제가 있었습니다.)
번외: OneToOne 관계의 데이터가 왜 null 일 수 밖에 없었는가?
이건 비즈니스 로직적인 특성 때문입니다. 해당 구독결제 방식은 Client 측에서 결제전 대상자들을 등록하기 위해 등록 API를 호출합니다. 이때, payments 테이블에 데이터를 쌓고, 이후 결제가 진행되면 해당 테이블의 결제 상태를 업데이트하며 간편 결제 및 캐시 결제 테이블로 데이터를 넘기고 이후 프로세스를 진행하는 방식이기 때문입니다.
반응형'Back-end Developer > Server, Spring' 카테고리의 다른 글
Spring Web MVC (0) 2021.04.07 MSA (0) 2021.03.24 RestTemplate & URLConnection (0) 2020.10.30 RPC (Remote Procedure Call) (0) 2020.10.29 REST API (2) (0) 2020.07.19