N+1 Problem (N+1 문제)
The N+1 queries problem is a common performance bottleneck in object-relational mapping (ORM) frameworks where multiple database queries are executed for related entities.
— 소프트웨어 디자인 패턴 및 ORM 아키텍처 공식 백서의 일침
데이터베이스는 죄가 없다. 객체와 관계형 데이터베이스 사이의 깊은 괴리인 '패러다임의 불일치'가 낳은 비극이자, SQL을 한 줄도 몰라도 DB 제어가 가능하다던 ORM 꼬드김에 속아 넘어간 자들의 최후. 로컬에서 조회 버튼 하나 누르고 콘솔창을 봤더니, 하이버네이트가 화면을 수놓는 수천 줄의 SELECT 로그를 폭포수처럼 쏟아낼 때의 뇌정지는 말로 다 표현하기 힘들다.(...)
1. 개요
ORM(Object-Relational Mapping) 프레임워크를 사용할 때 빈번하게 마주치는 대표적인 대형 가독성 및 성능 아킬레스건. 연관 관계를 맺고 있는 부모-자식 엔티티를 조회할 때, 개발자는 단 1번의 쿼리로 전체 조회를 시도했으나(1), 조회 결과로 나온 데이터 개수(N)만큼 연관 데이터를 추가 조회하는 쿼리가 N번 추가 실행되어 총 N+1번 데이터베이스를 드나드는 비효율적인 상황을 야기한다. RDBMS의 조인(Join) 연산 한 번이면 끝날 일을, 수백 수천 번의 쪼개진 쿼리 네트워크 송수신으로 분해해 서버를 혼수상태에 빠뜨리는 주범이다.
2. 어떻게 침묵의 살인마가 탄생하는가
이 현상은 ORM의 핵심 가치인 지연 로딩(Lazy Loading) 및 자율적인 프록시 객체 설계에서 비롯된다. 예를 들어, 회원(Member) 테이블과 그 회원이 소속된 팀(Team) 테이블이 N:1 연관 관계로 묶여 있다고 치자. 백엔드 개발자가 '전체 회원 목록을 조회해 오라'고 코딩하면, ORM은 충직하게 SELECT * FROM Member라는 최초 쿼리 1번(1)을 데이터베이스로 던진다. 이때 성능 최적화를 위해 팀 정보는 실제로 쓰이기 전까지 조회를 미루는 지연 로딩을 켠다. 하지만 진짜 지옥은 이후 가공 단계에서 터진다. 루프를 돌며 회원들의 소속 팀 이름을 화면에 렌더링하기 위해 member.getTeam().getName()을 호출하는 순간, 비어있던 팀 프록시 객체가 깜짝 놀라며 해당 회원의 팀 정보를 가져오기 위한 단건 조회 SELECT 쿼리를 회원마다 한 줄씩 던지기 시작(N)한다.1 회원이 100명이면 100번, 10,000명이면 10,000번의 추가 쿼리가 터져 나오는 것이다.
3. 해결책: 페치 조인과 배치 크기 조절
그렇다면 지연 로딩 대신 즉시 로딩(Eager Loading)을 쓰면 되지 않느냐고 반문하지만, 이는 더 처참한 종말을 부른다. 즉시 로딩은 사용자가 쓰지도 않는 데이터까지 매번 가져오느라 JPQL 컴파일러가 예측 불가능한 쿼리를 유발하기 때문이다.오히려 N+1이 상시 대기 상태로 고착화된다. 최고의 해결법은 SQL 수준에서 한꺼번에 조인을 수행하도록 ORM에게 명시적으로 명령하는 페치 조인(Fetch Join)을 활용하는 것이다. 또한, 한 번에 100개씩 나눠서 묶음으로 들고 오도록 세부 설정값을 지정하는 BatchSize 튜닝을 통해 쿼리 횟수를 지수함수적으로 압축할 수 있다.2 개발자들은 이 N+1 문제를 미연에 감지하기 위해 스프링 부트 설정에서 SQL 실행 쿼리 카운터를 자체 인터셉터로 달아 테스트 코드를 돌리거나, 쿼리 튜닝 전용 라이브러리인 Querydsl을 도입하여 정교한 벌크 조인 구문을 빌더 형식으로 사전에 하드코딩해 막는다.
4. 하이버네이트의 SELECT 폭포수
4.1. 콘솔을 뒤덮는 SELECT 매트릭스
스프링 백엔드 게시판 프로젝트에 글 10개를 썼을 뿐인데, 상세 조회 페이지에서 댓글 작성자 정보를 호출하는 로직이 꼬여 터져 나오는 에러 드립이다. 인텔리제이(IntelliJ) 개발 콘솔창이 온통 하얀색 SQL 로그로 빠르게 스크롤되며 올라갈 때, 마치 영화 매트릭스의 초록색 디지털 비가 내리는 씬을 방불케 한다고 하여 이름 붙었다. 분명 간단한 마이크로서비스인데 클라우드 데이터베이스인 AWS RDS 인프라 사용료가 수백만 원씩 과금되는 참사를 부르며, 심할 경우 이 쿼리 핑퐁 연타로 커넥션 풀(Connection Pool)이 순식간에 고갈되어 서비스 전체가 그대로 기절하기도 한다.(...)
5. 여담
- 조인 카티션 프로덕트의 함정: N+1 문제를 해결하겠다고 일대다(1:N) 관계 테이블 여러 개를 한 번에 페치 조인(Fetch Join)했다가는, 데이터 정합성이 깨지거나 중복된 레코드들이 곱하기 연산으로 불어나 메모리가 폭발하는 '카티션 곱(Cartesian Product)'의 또 다른 우주 헬게이트를 열게 된다.
- 로컬에선 안 터지는 가혹함: 개발 단계에서는 로컬 H2 데이터베이스 메모리에 데이터를 몇 건만 올려놓고 테스트하므로 N+1이 터져봤자 응답 속도가 0.001초 차이라 전혀 눈치채지 못한다. 그러나 실서버에 배포되어 데이터가 수천만 건으로 쌓이면 API 호출 속도가 50초를 돌파하는 고통스러운 병목으로 돌아와 개발자의 명줄을 단축시킨다.
- GraphQL의 BatchLoader: N+1 문제는 RDBMS와 백엔드 ORM 사이의 영역을 넘어 현대 프론트엔드 통신 아키텍처인 GraphQL 생태계에서도 고질적으로 나타난다. 이를 막기 위해 페이스북 진영은 여러 독립 요청을 하나로 모아서 한 방에 일괄 조회하는 DataLoader 패키지를 따로 개발해 보급해야 했다.