N+1 방지, 인덱스만 추가하면 해결되나?

결론부터 말하면 아니다. N+1의 본질은 쿼리 개수가 아니라 쿼리 패턴이고, 인덱스는 그 패턴을 바꾼 뒤에야 의미가 생긴다. 2026년 기준으로 이 순서를 거꾸로 하면—즉 패턴은 그대로 두고 인덱스만 늘리면—쓰기 비용만 키우고 왕복 횟수는 그대로인 경우를 실무에서 자주 본다.

이 글의 핵심

  • N+1 해결의 1순위는 조인·배치(IN)·프리페치 중 하나로 왕복 횟수를 줄이는 것이고, 인덱스는 그다음 단계다.
  • 배치 조회로 바꾼 뒤에는 WHERE parent_id IN (...)에 맞는 인덱스를 다시 검증해야 한다. 정렬이 있으면 (parent_id, created_at) 같은 복합 인덱스가 필요하다.[1]
  • 읽기 비율이 높고 일관성 요구가 낮은 경로는 배치+인덱스가 우선순위가 높고, 쓰기 비율이 높을수록 인덱스 수는 제한해야 한다.
  • 격리 수준은 N+1 자체를 해결하지 않는다. 여러 쿼리로 나뉜 조회가 같은 시점의 데이터를 봐야 하는지는 별도 판단이다.[4]

판단은 보통 하나의 질문에서 갈린다. "이 목록 화면의 부모-자식 결과가 하나의 시점에 일치해야 하는가, 아니면 약간의 시차를 감수할 수 있는가." 이 답에 따라 배치 조회만으로 끝낼지, 격리 수준까지 손댈지가 갈린다.

배치 조회와 조인, 무엇을 기준으로 고르나?

기준은 자식 데이터의 카디널리티와 화면에서 필요한 집계 여부다. 부모 1건당 자식이 적고(예: 평균 3~5건) 단순 나열이면 조인으로 한 번에 가져오는 편이 결과 처리가 간단하다. 반면 자식이 많거나(평균 수십~수백 건) 부모별로 자식 개수나 최근 N건만 필요하면 조인은 중복 행을 과도하게 만들어 전송량이 커지므로, 부모 ID 목록을 모아 IN 조건으로 자식을 한 번에 조회하는 배치 방식이 더 다루기 쉽다.

여기서 흔히 놓치는 지점은 "배치로 바꿨으니 끝"이라고 생각하는 것이다. 배치 조회로 바꾼 직후에는 child(parent_id) 인덱스가 있는지, 정렬이나 상태 필터가 붙었다면 (parent_id, created_at)처럼 조건 순서에 맞춘 복합 인덱스가 있는지를 EXPLAIN (ANALYZE, BUFFERS)로 확인해야 한다. 복합 B-tree 인덱스는 선두 컬럼 조건이 핵심이라, (parent_id, created_at) 인덱스는 parent_id만 또는 parent_id+created_at 조건에는 맞지만 created_at만 조건으로 쓰는 쿼리에는 효과가 제한적이다.[1]

인덱스를 늘릴지 말지, 읽기·쓰기 비율로 어떻게 가르나?

읽기 비율이 압도적으로 높은 경로에는 커버링 인덱스를 검토할 수 있지만, 쓰기가 섞인 테이블이라면 인덱스 수를 제한하는 편이 안전하다. B-tree 리프에 조회에 필요한 추가 컬럼을 포함해 테이블 접근 자체를 줄이는 구성(흔히 index-only/covering query라 부른다)은 읽기 비용을 낮출 수 있지만, 그만큼 인덱스 크기가 커지고 매 INSERT·UPDATE마다 갱신해야 할 인덱스 항목이 늘어난다.[1]

2025년 인덱스 비교 연구는 이 트레이드오프를 구조적으로 보여준다. 실데이터·합성 데이터 12종, 최대 8억 개 원소를 대상으로 조회 시간뿐 아니라 인덱스 크기, 메모리 사용량, 구축 시간, 존재하지 않는 키 조회 시간까지 함께 측정했는데, 조회 시간 하나만 보고 인덱스 구조를 고르면 구축 비용과 메모리 사용량에서 손해를 볼 수 있다는 게 핵심 메시지다.[2] 같은 연구에서 SIMD로 강화한 B-tree가 비슷한 추가 공간으로 조회 시간에서 꾸준히 좋은 결과를 보였지만, 이 결과는 정적 정수 데이터셋 중심이라 INSERT·UPDATE가 잦은 일반 서비스 스키마에 그대로 적용하기는 어렵다.[2]

이 연구가 N+1 인덱스 설계에 주는 실무 시사점은 단순하다: 자식 테이블에 인덱스를 추가할 때는 조회 시간만 보지 말고 쓰기 지연 증가와 인덱스 크기를 함께 재야 한다는 것이다. 삭제되지 않은 행, 처리 대기 상태처럼 선택도가 높은 고정 조건이 항상 붙는 쿼리라면 전체 인덱스 대신 부분 인덱스를 후보로 둘 수 있지만, 이 글이 참고한 자료에는 PostgreSQL·MySQL 같은 특정 제품의 부분 인덱스 수치가 포함되어 있지 않아 수치화된 우열은 제시하지 않는다.

정규화와 비정규화, 언제 어느 쪽을 선택하나?

자식 데이터를 자주 함께 읽고 변경이 드물면 집계 테이블이나 읽기 모델을 고려할 수 있고, 쓰기 빈도와 일관성 요구가 높으면 정규화된 구조와 명시적 조인이 더 안전한 출발점이다. 예를 들어 주문 목록 화면에서 주문별 상품 수를 매번 집계 쿼리로 계산하는 대신 order_item_count 같은 컬럼을 비정규화해 두면 N+1을 아예 피할 수 있지만, 상품이 추가·취소될 때마다 그 컬럼을 갱신하는 지점이 늘어난다.

이 선택에서 놓치기 쉬운 부분은 "비정규화하면 N+1이 사라진다"는 것과 "쓰기 경로가 복잡해진다"는 것이 같은 변경의 양면이라는 점이다. 집계 컬럼을 하나 추가하면 그 값을 갱신해야 하는 트랜잭션 경계가 늘고, 갱신을 빠뜨리면 조회 결과와 실제 상태가 어긋나는 더 조용한 버그가 생긴다. 그래서 비정규화는 읽기 경로의 N+1 수보다, 그 값을 갱신해야 하는 쓰기 경로 수가 적고 명확할 때 우선 검토하는 편이 다루기 쉽다.

트랜잭션 격리 수준은 N+1 제거와 같이 고려해야 하나?

경우에 따라 다르다. 격리 수준은 N+1 자체를 해결하지 않지만, 부모 조회와 자식 조회를 여러 문장으로 나눈 상태에서는 그 사이에 다른 트랜잭션의 변경이 끼어들 수 있다는 점을 같이 봐야 한다. PostgreSQL의 Repeatable Read는 트랜잭션 시작 전에 커밋된 데이터만 일관된 스냅샷으로 보고, 실행 중 다른 트랜잭션의 커밋 변경은 보지 않는다.[4] Serializable은 이보다 더 나아가 커밋된 트랜잭션들을 직렬로 실행한 것과 같은 결과를 보장하는 가장 엄격한 수준이다.[4]

문제는 비용이다. 2025년 AWS가 소개한 PostgreSQL 사례에서는 Read Committed와 Repeatable Read가 비슷한 성능을 보였지만, 충돌이 늘어나는 구간에서는 Serializable의 처리량이 크게 떨어졌다.[6] 그래서 목록 조회처럼 읽기 중심이고 약간의 시차를 감수할 수 있는 경로는 기본 격리 수준을 유지하고, 재고 차감이나 중복 결제 방지처럼 불변 조건이 중요한 짧은 트랜잭션에만 더 강한 격리를 적용하는 식으로 경로를 분리하는 편이 합리적이다.

이 선택을 실측하려면 어떤 조건을 맞춰야 하나?

pgbench는 사용자 정의 SQL로 N+1 전후의 쿼리 흐름을 재현할 수 있는 도구다. 기본 시나리오는 트랜잭션당 SELECT, UPDATE, INSERT를 포함한 TPC-B 유사 작업이지만, 쓰기 경합을 배제하고 읽기 경로만 비교하려면 select-only 모드나 사용자 정의 스크립트로 분리해 측정해야 한다.PostgreSQL pgbench 문서 PostgreSQL 문서는 초기화 스케일 팩터(-s)가 동시 클라이언트 수(-c)보다 작으면 실제 쿼리 비용 대신 업데이트 경합을 측정하게 될 수 있다고 명시한다. 그래서 N+1 전후 비교 글에는 -s, -c, 실행 시간, 워밍업 여부를 반드시 적어야 한다.

측정표에는 최소한 다음이 들어가야 한다: ORM·DB 버전, 데이터 건수와 부모-자식 카디널리티, N+1 전후 총 SQL 수와 DB 왕복 횟수, 평균·p50·p95·p99 지연시간, 초당 처리량과 버퍼 캐시 적중률, 인덱스 정의와 EXPLAIN (ANALYZE, BUFFERS) 결과, 격리 수준과 직렬화 실패·재시도 횟수. 이 항목 중 하나라도 빠지면 "N+1을 없앴더니 빨라졌다"는 주장은 재현 불가능한 관측으로 남는다.

함정은 어디에 있나?

가장 흔한 함정은 배치 조회로 바꾸면서 인덱스 검증을 건너뛰는 것이다. 쿼리 수는 N+1에서 1+1로 줄었지만 IN 조건에 쓰인 컬럼에 인덱스가 없으면 테이블 전체 스캔 한 번이 N번의 인덱스 조회보다 더 느려지는 경우도 있다. 두 번째 함정은 격리 수준을 과도하게 올리는 것이다. 모든 목록 조회 경로에 Serializable을 적용하면 AWS 사례처럼 충돌이 늘어나는 구간에서 처리량이 크게 꺾일 수 있는데,[6] 이 비용은 평소에는 드러나지 않다가 트래픽이 몰리는 시점에 재시도 실패로 나타나는 경우가 많다. 세 번째 함정은 인덱스를 추가할 때 조회 시간만 보고 쓰기 비용과 인덱스 크기를 재지 않는 것이다. 2025년 인덱스 비교 연구가 구축 시간과 메모리 사용량을 평가 지표에 넣은 이유도 여기에 있다.[2]

결론: 어떤 상황에 무엇을 고르나

  • 자식 카디널리티가 낮고 단순 나열이면 조인, 높거나 집계가 필요하면 배치(IN) 조회
  • 배치 조회로 바꾼 뒤에는 parent_id 선두 복합 인덱스를 EXPLAIN으로 반드시 검증
  • 읽기 비율이 높은 경로에 한정해 커버링 인덱스·부분 인덱스 검토, 쓰기 비율이 높으면 인덱스 수 제한
  • 자주 함께 읽고 드물게 바뀌면 비정규화, 쓰기 빈도·일관성 요구가 높으면 정규화 유지
  • 시점 일치가 중요한 짧은 트랜잭션만 격리 수준을 올리고, 목록 조회 전반에 일괄 적용하지 않기

핵심 정리

  • N+1 해결은 쿼리 패턴 변경(조인·배치·프리페치)이 먼저고, 인덱스는 그 이후 검증 단계다.
  • 복합 인덱스는 선두 컬럼 조건에 좌우되므로 배치 조건과 정렬 순서에 맞춰 설계해야 한다.[1]
  • 인덱스 선택은 조회 시간뿐 아니라 구축 시간·메모리 사용량·쓰기 비용을 함께 재야 한다.[2]
  • 격리 수준은 N+1을 해결하지 않으며, 충돌이 느는 구간에서 Serializable의 처리량 저하가 두드러질 수 있다.[6]
  • 실측 비교에는 데이터 규모, 클라이언트 수, 쿼리 수, p95/p99, 인덱스 실행계획, 격리 수준을 모두 기록해야 재현 가능하다.