# 2026년 Django와 PostgreSQL: 인덱싱, 전문 검색, 면접 질문 > Django PostgreSQL 최적화 실전 가이드: B-tree, 부분 인덱스, 커버링 인덱스, SearchVector와 GIN을 활용한 전문 검색, 그리고 2026년 면접 질문까지 다룹니다. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- Django PostgreSQL 최적화는 첫 사용자 1,000명을 넘겨 확장되는 애플리케이션과 목록 화면마다 멈춰버리는 애플리케이션을 가르는 기준입니다. PostgreSQL은 강력한 인덱싱 엔진과 전문 검색 서브시스템을 기본으로 제공하지만, 대부분의 Django 프로젝트는 이를 그대로 방치한 채 아무 비용 없이 되찾을 수 있는 쿼리 성능을 낭비합니다. 이 글은 인덱싱 패턴, `django.contrib.postgres` 검색 도구, 그리고 2026년 중급과 시니어 Django 엔지니어를 가르는 데이터베이스 면접 질문을 다룹니다. > **인덱스를 추가하기 전에 먼저 측정하세요** > > 모든 인덱스는 읽기를 빠르게 하지만 쓰기를 느리게 만들고 디스크를 소모합니다. 실제 쿼리에 `EXPLAIN (ANALYZE, BUFFERS)`를 실행하고, 인덱스를 추가한 뒤 실행 계획을 비교하세요. 쿼리 플래너가 한 번도 선택하지 않는 인덱스는 모든 INSERT와 UPDATE에 순수한 부담만 더할 뿐입니다. ## Django에서의 PostgreSQL 인덱싱 기초 PostgreSQL은 기본적으로 기본 키와 `unique=True`가 지정된 컬럼에만 B-tree 인덱스를 생성합니다. 나머지 필터링되거나 정렬되는 컬럼은 인덱스가 존재하기 전까지 순차 스캔으로 처리됩니다. Django는 `Meta.indexes`를 통해 인덱스를 정의하며, 복합 인덱스와 부분 인덱스, 커버링 인덱스를 한곳에서 지원하기 때문에 예전 방식인 `db_index=True`보다 권장됩니다. 복합 인덱스에서 컬럼 순서는 단순한 형식이 아닙니다. B-tree는 쿼리 필터가 인덱스 컬럼의 선행 접두사를 사용할 때만 인덱스를 활용할 수 있으며, 이 규칙은 [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys)에서 자세히 설명합니다. `(status, created_at)` 인덱스는 `WHERE status = ? ORDER BY created_at` 쿼리에는 도움이 되지만, `created_at`만으로 필터링하는 쿼리는 같은 인덱스로 가속할 수 없습니다. ```python # models.py from django.db import models class Order(models.Model): customer_email = models.EmailField() status = models.CharField(max_length=20) total = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # status에 대한 정확 일치 및 범위 조회용 단일 컬럼 B-tree models.Index(fields=["status"]), # 복합 인덱스: WHERE status = ? ORDER BY created_at DESC 쿼리 처리 # 선행 컬럼(status)이 필터에 나타나야만 사용됨 models.Index(fields=["status", "-created_at"]), ] ``` 이 복합 인덱스 덕분에 "최근 대기 중인 주문"과 같은 대시보드 쿼리는 전체 테이블을 필터링한 뒤 정렬하는 대신 단일 인덱스 스캔으로 처리됩니다. ## 타깃 쿼리를 위한 부분 인덱스와 커버링 인덱스 부분 인덱스는 조건에 일치하는 행만 포함하므로 크기가 작게 유지되고 메모리에 상주합니다. 대부분의 행이 같은 값을 공유하고 쿼리가 소수의 행만 다루는 경우, 부분 인덱스는 전체 인덱스보다 훨씬 저렴합니다. 커버링 인덱스는 여기서 한 걸음 더 나아갑니다. `include`로 키가 아닌 컬럼을 추가하면 PostgreSQL은 테이블 힙에 접근하지 않고 인덱스에서 직접 쿼리에 응답하며, 이러한 접근 방식을 인덱스 전용 스캔(index-only scan)이라고 합니다. ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # 위에서 정의한 필드들 class Meta: indexes = [ # 부분 인덱스: 완료된 이력은 무시하고 대기 중인 행만 인덱싱 models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # 커버링 인덱스: total을 인덱스에서 바로 읽음(인덱스 전용 스캔) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` 전체 행의 2%만 대기 중인 주문 테이블에서 부분 인덱스는 같은 컬럼에 대한 전체 인덱스보다 50배 작을 수 있으며, 플래너는 이를 버퍼 캐시에 계속 유지합니다. `include` 절은 [PostgreSQL 인덱스 전용 스캔](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) 문서에 설명되어 있으며, 상태 필터 뒤에서 숫자 컬럼을 집계하는 Django 애플리케이션에서 가장 자주 간과되는 성능 개선 포인트입니다. ## PostgreSQL을 활용한 Django 전문 검색 `title__icontains=query`는 편리해 보이지만 순차 스캔을 강제하고 결과를 관련도순으로 정렬할 수 없습니다. Django 전문 검색은 `SearchVector`, `SearchQuery`, `SearchRank`를 통해 PostgreSQL의 기본 타입인 `tsvector`와 `tsquery`를 감쌉니다. 검색 벡터는 텍스트를 어휘소(lexeme)로 정규화하여 불용어를 제거하고 단어를 어간으로 축약합니다. 그래서 "running"을 검색하면 "run"과 "ran"도 함께 매칭됩니다. ```python # views.py from django.contrib.postgres.search import ( SearchVector, SearchQuery, SearchRank, ) from .models import Article def search_articles(query_text): query = SearchQuery(query_text, config="english") # 가중치 A는 제목 매칭을 본문 매칭(가중치 B)보다 상위로 정렬 vector = ( SearchVector("title", weight="A") + SearchVector("body", weight="B") ) return ( Article.objects.annotate(rank=SearchRank(vector, query)) .filter(rank__gte=0.1) .order_by("-rank") ) ``` `weight` 매개변수는 A(가장 높음)부터 D까지 우선순위 구간을 지정합니다. 제목과 본문 모두 검색어를 포함하더라도 제목 매칭이 본문 매칭보다 상위에 정렬되며, 이는 사용자가 검색에 기대하는 동작과 일치합니다. 전체 API와 4단계 가중치 모델은 [Django 전문 검색 레퍼런스](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/)에서 다룹니다. ## 빠른 검색을 위한 GIN 인덱스와 SearchVectorField 위 쿼리는 요청마다 모든 행의 검색 벡터를 다시 계산합니다. 수천 행 규모에서는 문제없지만 수백만 행에서는 감당할 수 없습니다. 프로덕션 패턴은 벡터를 `SearchVectorField`에 저장하고 GIN 인덱스로 인덱싱합니다. GIN은 전문 검색 문서나 JSONB 같은 복합 값에 PostgreSQL이 사용하는 인덱스 타입입니다. ```python # models.py from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex from django.db import models class Article(models.Model): title = models.CharField(max_length=255) body = models.TextField() # 미리 계산되어 저장된 검색 문서 search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN은 전문 검색 조회를 로그 시간 인덱스 스캔으로 전환 GinIndex(fields=["search_vector"]), ] ``` 저장된 필드는 `title` 및 `body`와 동기화된 상태를 유지해야 합니다. `.update()`로 벡터를 기록하는 `post_save` 시그널은 시그널을 재귀적으로 발생시키지 않습니다. `QuerySet.update()`는 save 시그널을 방출하지 않기 때문입니다. ```python # signals.py from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from .models import Article @receiver(post_save, sender=Article) def sync_search_vector(sender, instance, **kwargs): Article.objects.filter(pk=instance.pk).update( search_vector=SearchVector("title", weight="A") + SearchVector("body", weight="B"), ) ``` Django 5.0 이상을 사용하는 팀이라면 데이터베이스에서 계산되는 `GeneratedField`나 PostgreSQL 트리거로 컬럼을 데이터베이스 내부에서 관리하여 추가 UPDATE를 완전히 제거할 수 있습니다. 어떤 방식으로 값을 채우든, 쿼리는 인덱싱된 필드를 직접 필터링하게 되고 GIN 인덱스는 전체 테이블 스캔이던 작업을 로그 시간 조회로 바꿉니다. > **GIN 인덱스는 쓰기 비용이 큽니다** > > GIN 인덱스는 읽기를 빠르게 하지만 실질적인 쓰기 비용을 치릅니다. 각 삽입이 여러 인덱스 항목을 건드리기 때문입니다. 쓰기가 많은 테이블에서는 `fastupdate` 저장 매개변수를 설정하고 대기 목록(pending-list) 크기를 모니터링하세요. 그렇지 않으면 전문 검색 쓰기가 인덱스 유지 작업에 막혀 지연됩니다. ## 적절한 PostgreSQL 인덱스 타입 선택하기 B-tree는 올바른 기본값이지만, PostgreSQL은 `django.contrib.postgres.indexes`를 통해 여러 인덱스 타입을 제공하며, 잘못된 타입을 고르면 단 하나의 쿼리도 개선하지 못한 채 디스크만 낭비합니다. 선택은 데이터의 형태와 쿼리가 사용하는 연산자를 따르며, 이는 [PostgreSQL 인덱스 타입 문서](https://www.postgresql.org/docs/current/indexes-types.html)에 정리되어 있습니다. | 인덱스 타입 | 적합한 용도 | Django 클래스 | |------------|----------|--------------| | B-tree | 스칼라 컬럼의 동등 및 범위 조회 | `models.Index` | | GIN | 전문 검색, JSONB, 배열 포함 여부 | `GinIndex` | | BRIN | 컬럼 순으로 정렬된 대규모 추가 전용 테이블 | `BrinIndex` | | Hash | 대용량 컬럼의 동등 전용 조회 | `HashIndex` | BRIN은 시계열 데이터에서 특별히 주목할 만합니다. 타임스탬프 순으로 수천만 행이 삽입된 테이블에서 `created_at`에 대한 `BrinIndex`는 몇 킬로바이트만 차지하지만, 같은 상황에서 B-tree는 수백 메가바이트가 필요합니다. BRIN은 블록 범위마다 최솟값과 최댓값만 저장하기 때문입니다. 절충점은 BRIN이 물리적 행 순서가 인덱싱된 컬럼을 따를 때만 도움이 된다는 것이며, 이는 추가 전용 로그와 이벤트 테이블에서 자연스럽게 성립합니다. ```python # models.py from django.contrib.postgres.indexes import BrinIndex from django.db import models class Event(models.Model): payload = models.JSONField() created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # 추가 전용이고 시간순으로 정렬된 테이블의 범위 스캔용 초소형 인덱스 BrinIndex(fields=["created_at"]), ] ``` 인덱스 타입을 접근 패턴에 맞추는 것은 시니어 면접에서 반복적으로 등장하는 주제입니다. 아무 데나 반사적으로 B-tree를 추가하는 대신 저장 구조를 고려해 판단한다는 것을 증명하기 때문입니다. ## 2026년 Django 데이터베이스 면접 질문 데이터베이스 질문은 시니어 Django 면접을 지배합니다. ORM을 능숙하게 다룬다고 해서 ORM이 실제로 어떤 SQL을 내보내는지 이해하는 것은 아니기 때문입니다. 아래 질문들은 2026년 채용 면접관이 실제로 파고드는 지점을 반영하며, [Django ORM 쿼리 최적화 가이드](/blog/django/django-orm-optimizing-queries)에서 다루는 쿼리 수준 튜닝과 자연스럽게 이어집니다. **`(a, b)`에 대한 복합 인덱스는 언제 쿼리에 도움이 되지 않을까요?** 쿼리가 `a`로 필터링하지 않을 때입니다. B-tree는 선행 컬럼을 기준으로 먼저 정렬되므로 `b`만으로 필터링하면 인덱스를 사용할 수 없습니다. 이것이 최좌측 접두사(leftmost-prefix) 규칙이며, 이를 설명할 수 있는 지원자는 암기한 공식이 아니라 인덱스 구조를 이해하고 있음을 보여줍니다. **`select_related`와 `prefetch_related`의 차이는 무엇일까요?** `select_related`는 SQL JOIN을 수행하며 외래 키와 일대일 관계를 단일 쿼리로 처리합니다. `prefetch_related`는 두 번째 쿼리를 실행한 뒤 결과를 파이썬에서 결합하며, 다대다 관계와 역방향 외래 키 관계에 필요합니다. 잘못된 것을 고르면 최적화를 놓치거나 불필요한 두 번째 쿼리를 유발합니다. **`icontains`는 데이터베이스 수준에서 전문 검색과 어떻게 다를까요?** `icontains`는 `ILIKE '%term%'`로 컴파일되며, 표준 B-tree 인덱스를 사용할 수 없어 모든 행을 스캔합니다. 전문 검색은 GIN 인덱스로 뒷받침되는 미리 계산된 `tsvector`와 매칭하고 결과를 관련도순으로 정렬합니다. 이 차이는 지원자가 장난감 수준의 데이터셋을 넘어섰다는 믿을 만한 신호입니다. **대규모 테이블에서 `count()`가 느릴 수 있는 이유는 무엇이고, 대안은 무엇일까요?** PostgreSQL은 테이블의 행 개수를 캐시해 두지 않으므로 `COUNT(*)`는 MVCC 하에서 보이는 모든 행을 스캔합니다. 근사치가 필요하다면 `pg_class.reltuples`를 조회하여 추정값을 즉시 얻을 수 있고, 페이지네이션에는 인덱싱된 컬럼에 대한 키셋 페이지네이션이 개수 계산 자체를 피합니다. 이러한 패턴에 대한 체계적인 연습은 캐시 기반 카운터가 반복해서 등장하는 [Django 캐싱 면접 모듈](/technologies/django/interview-questions/django-caching)과 전체 [Django 학습 트랙](/technologies/django)에서 제공됩니다. **`EXPLAIN ANALYZE`는 `EXPLAIN`만으로는 알 수 없는 무엇을 드러낼까요?** `EXPLAIN`은 플래너가 추정한 비용을 출력하지만, `EXPLAIN ANALYZE`는 실제로 쿼리를 실행하여 실제 소요 시간과 행 개수를 보고합니다. 추정 행 수와 실제 행 수 사이의 큰 격차는 오래된 테이블 통계를 가리키며, 이는 `ANALYZE`로 해결할 수 있습니다. 또한 당연히 적용되어야 할 인덱스를 플래너가 무시하는 흔한 근본 원인이기도 합니다. ## 결론 - 복합, 부분, 커버링 인덱스를 검토 가능한 한곳에 두기 위해 `db_index=True` 대신 `Meta.indexes`에 인덱스를 정의합니다. - 복합 인덱스 컬럼은 최좌측 접두사 규칙에 따라 배치합니다. 인덱스가 적용되려면 선행 컬럼이 쿼리 필터에 나타나야 합니다. - 완료된 이력이 대부분인 테이블의 대기 중인 주문처럼, 쿼리가 소수의 행만 다룰 때는 부분 인덱스를 사용합니다. - 읽기가 많은 집계 쿼리에서 힙 접근을 건너뛰고 인덱스 전용 스캔을 활성화하려면 `include`로 커버링 인덱스를 추가합니다. - `icontains` 검색을 `SearchVector`, `SearchQuery`, `SearchRank`로 대체하면 관련도 순위와 어간 추출을 별도 비용 없이 얻습니다. - 전문 검색이 프로덕션 규모에 이르기 전에, 검색 문서를 `GinIndex`로 뒷받침되는 `SearchVectorField`에 저장하고 시그널, `GeneratedField`, 또는 데이터베이스 트리거로 동기화합니다. - 인덱스 결정은 항상 `EXPLAIN (ANALYZE, BUFFERS)`로 검증합니다. 플래너가 절대 선택하지 않는 인덱스는 읽기 이득 없이 쓰기 시점에 세금만 부과합니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/django/django-postgresql-indexing-full-text-search-2026