인덱스는 검색을 빠르게 만드는 기능이 아니다

vx_developer·어제

개발하다가

목록 보기
35/37
post-thumbnail

프로그래밍을 처음 배울 때 데이터베이스 인덱스는 보통 책의 색인처럼 원하는 데이터를 빠르게 찾도록 도와주는 기능이라고 배운다.

CREATE INDEX idx_shipments_tracking_number
ON shipments (tracking_number);

이 인덱스가 있으면 데이터베이스가 전체 배송 데이터를 하나씩 확인하지 않고도 특정 운송장 번호를 찾을 수 있다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 단순히 “조회가 느리니 인덱스를 추가한다”는 판단만으로는 부족하다.

  • 어떤 조회를 빠르게 만들어야 하는가?
  • 인덱스의 컬럼 순서는 왜 중요한가?
  • 조회 조건에 포함된 컬럼마다 인덱스를 만들어야 하는가?
  • 데이터가 적을 때도 인덱스가 필요한가?
  • 인덱스가 많아지면 저장과 수정에는 어떤 비용이 생기는가?
  • 정렬과 페이지네이션도 같은 인덱스를 사용할 수 있는가?
  • 실제로 인덱스가 사용되는지는 어떻게 확인하는가?

인덱스는 단순히 검색을 빠르게 만드는 기능이 아니다.

인덱스는 자주 사용되는 조회 경로를 미리 구성하는 대신 저장 공간과 데이터 변경 비용을 지불하는 읽기 성능 설계다.


느린 조회는 테이블이 아니라 사용자의 행동에서 시작된다

크리스가 배송 조회 서비스를 개발한다고 생각해 보자.

사용자는 운송장 번호로 배송 상태를 조회하고, 운영자는 처리되지 않은 배송 목록을 날짜순으로 확인한다.

CREATE TABLE shipments (
  id UUID PRIMARY KEY,
  tracking_number TEXT NOT NULL,
  customer_id UUID NOT NULL,
  status TEXT NOT NULL,
  destination_postcode TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL,
  delivered_at TIMESTAMPTZ
);

처음에는 데이터가 적기 때문에 어떤 조회도 빠르게 동작한다.

SELECT *
FROM shipments
WHERE tracking_number = 'AU123456789';

배송 데이터가 수십 건뿐이라면 전체 행을 확인해도 사용자가 차이를 느끼기 어렵다. 그러나 데이터가 계속 쌓이면 같은 조회의 비용도 증가한다.

이때 “shipments 테이블이 느리다”라고 표현하면 문제를 정확히 설명하기 어렵다. 테이블 자체가 빠르거나 느린 것이 아니라 특정한 조회 방식이 현재 데이터 구조와 맞지 않는 것이다.

배송 서비스에는 서로 다른 조회 경로가 존재할 수 있다.

고객 배송 조회
tracking_number = ?

내 배송 목록
customer_id = ?
ORDER BY created_at DESC

운영자 미처리 목록
status IN (...)
ORDER BY created_at ASC

지역별 배송 통계
destination_postcode = ?
AND created_at BETWEEN ? AND ?

같은 테이블을 조회하더라도 조건, 정렬, 반환 범위가 서로 다르다. 하나의 인덱스가 모든 조회를 동일하게 빠르게 만들지는 않는다.

따라서 인덱스를 설계할 때는 테이블 이름보다 먼저 실제 사용자의 행동과 실행되는 쿼리를 확인해야 한다.


인덱스가 없으면 데이터베이스는 확인할 수 있는 행을 직접 읽는다

운송장 번호에 인덱스가 없다고 생각해 보자.

SELECT *
FROM shipments
WHERE tracking_number = 'AU123456789';

데이터베이스는 조건에 맞는 행을 찾기 위해 많은 배송 데이터를 확인해야 할 수 있다.

개념적으로는 다음과 비슷한 작업이다.

const shipment = shipments.find(
  (shipment) =>
    shipment.trackingNumber === "AU123456789"
);

배열의 앞부분에서 찾을 수도 있지만 마지막까지 확인해야 할 수도 있다. 데이터가 늘어날수록 확인해야 하는 후보도 많아진다.

운송장 번호를 기준으로 인덱스를 만들면 데이터베이스는 별도의 정렬된 탐색 구조를 유지할 수 있다.

CREATE INDEX idx_shipments_tracking_number
ON shipments (tracking_number);

이제 운송장 번호를 통해 후보 위치를 찾은 뒤 실제 배송 행을 읽을 수 있다.

그러나 인덱스가 있다고 해서 데이터를 읽는 비용이 사라지는 것은 아니다.

인덱스에서 조건에 맞는 위치 탐색
        ↓
해당 위치가 가리키는 테이블 행 확인
        ↓
필요한 컬럼 반환

인덱스 탐색, 테이블 접근, 결과 전송에는 각각 비용이 있다. 조건에 맞는 데이터가 테이블의 대부분이라면 인덱스를 거치는 것보다 전체 데이터를 읽는 편이 더 나을 수도 있다.

인덱스는 항상 사용되는 지름길이 아니다. 데이터베이스는 예상 비용을 비교한 뒤 인덱스를 사용할지 결정한다.


인덱스는 컬럼이 아니라 조회 패턴을 위해 만든다

크리스가 배송 테이블의 여러 컬럼에 각각 인덱스를 추가한다고 생각해 보자.

CREATE INDEX idx_shipments_customer_id
ON shipments (customer_id);

CREATE INDEX idx_shipments_status
ON shipments (status);

CREATE INDEX idx_shipments_created_at
ON shipments (created_at);

각 컬럼이 자주 사용된다는 이유만으로 개별 인덱스를 만들었지만, 실제 화면의 쿼리는 다음과 같을 수 있다.

SELECT
  id,
  tracking_number,
  status,
  created_at
FROM shipments
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT 20;

이 쿼리는 특정 고객의 배송만 찾은 뒤 최신 순서로 20개를 반환한다.

customer_id 인덱스는 고객의 배송을 찾는 데 도움을 줄 수 있지만, 찾은 결과를 다시 created_at으로 정렬해야 할 수 있다. created_at 인덱스만 사용하면 전체 사용자의 배송이 섞인 상태에서 특정 고객의 데이터를 찾느라 많은 항목을 확인할 수 있다.

실제 조회 패턴을 함께 표현하는 복합 인덱스를 고려할 수 있다.

CREATE INDEX idx_shipments_customer_created_at
ON shipments (customer_id, created_at DESC);

이 인덱스는 같은 고객의 배송을 created_at 순서로 탐색할 수 있도록 구성된다. 따라서 조건 검색과 정렬을 하나의 접근 경로로 처리할 가능성이 높아진다.

중요한 것은 “어떤 컬럼이 자주 쓰이는가”만 확인하는 것이 아니다.

  • 어떤 컬럼이 같은 쿼리에서 함께 사용되는가?
  • 어떤 조건이 먼저 검색 범위를 줄이는가?
  • 결과를 어떤 순서로 반환해야 하는가?
  • 한 번에 몇 개의 행을 읽는가?
  • 목록의 다음 페이지는 어떻게 찾는가?

인덱스는 컬럼에 붙이는 성능 옵션이 아니라 반복되는 조회 패턴을 데이터 구조로 표현하는 방법이다.


복합 인덱스의 순서는 서비스가 데이터를 찾는 순서를 반영한다

다음 두 인덱스는 같은 컬럼을 포함하지만 같은 역할을 하지 않는다.

CREATE INDEX idx_shipments_customer_created
ON shipments (customer_id, created_at DESC);

CREATE INDEX idx_shipments_created_customer
ON shipments (created_at DESC, customer_id);

첫 번째 인덱스는 고객별로 데이터를 모은 뒤 각 고객 안에서 생성 시각 순서를 유지한다.

customer-a → 2026-09-25
customer-a → 2026-09-24
customer-b → 2026-09-25
customer-b → 2026-09-23

따라서 다음 조회와 잘 맞는다.

SELECT *
FROM shipments
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT 20;

반면 두 번째 인덱스는 전체 배송을 생성 시각순으로 정리한 뒤 같은 시각 범위 안에서 고객 ID를 사용한다. 전체 최신 배송을 조회할 때는 유용할 수 있지만 고객 한 명의 전체 이력을 찾는 방식에는 적합하지 않을 수 있다.

복합 인덱스는 일반적으로 앞쪽 컬럼을 기준으로 탐색 범위를 구성한다. 따라서 다음 조회를 위해 만든 인덱스의 순서를 단순히 알파벳순이나 테이블 컬럼 순서로 정해서는 안 된다.

WHERE customer_id = $1
  AND status = 'in_transit'
ORDER BY created_at DESC

이 쿼리에는 다음과 같은 인덱스를 검토할 수 있다.

CREATE INDEX idx_shipments_customer_status_created
ON shipments (
  customer_id,
  status,
  created_at DESC
);

이 구조는 고객과 상태로 범위를 좁힌 뒤 생성 시각순으로 결과를 읽는 조회 패턴을 반영한다.

그러나 이것이 모든 상황의 정답은 아니다. 다음 요소에 따라 순서는 달라질 수 있다.

  • 각 조건이 결과 범위를 얼마나 줄이는가?
  • 어떤 조건이 항상 존재하는가?
  • 범위 조건이 포함되는가?
  • 어떤 정렬이 필요한가?
  • 다른 중요한 쿼리에서도 인덱스를 재사용해야 하는가?

복합 인덱스의 순서는 컬럼의 중요도 순위가 아니다. 데이터베이스가 특정 쿼리를 위해 데이터를 탐색하는 경로다.


값의 종류가 적으면 단일 인덱스의 효과도 제한될 수 있다

배송 상태에는 몇 가지 값만 존재할 수 있다.

type ShipmentStatus =
  | "pending"
  | "picked_up"
  | "in_transit"
  | "delivered"
  | "cancelled";

status 컬럼에 인덱스를 만들 수 있다.

CREATE INDEX idx_shipments_status
ON shipments (status);

하지만 전체 배송의 절반 이상이 delivered 상태라면 다음 조회는 매우 많은 행을 반환한다.

SELECT *
FROM shipments
WHERE status = 'delivered';

인덱스를 사용하더라도 수많은 인덱스 항목과 테이블 행을 읽어야 한다. 데이터베이스는 전체 테이블을 순차적으로 읽는 편이 더 저렴하다고 판단할 수 있다.

인덱스가 효과적이려면 조건이 읽어야 할 후보를 의미 있게 줄이는 경우가 많아야 한다. 이를 판단할 때는 값의 선택도를 살펴볼 수 있다.

  • 운송장 번호는 대부분 고유하므로 한 행으로 빠르게 좁혀진다.
  • 고객 ID는 특정 사용자의 배송 범위로 줄일 수 있다.
  • 배송 상태는 값의 종류가 적어 단독으로는 많은 행이 남을 수 있다.
  • 생성 날짜는 범위 조건에 따라 결과 수가 크게 달라진다.

상태 조회가 중요하다면 상태만 보지 않고 실제 화면의 조건을 함께 인덱스에 반영할 수 있다.

SELECT *
FROM shipments
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 100;

이 조회에는 다음 인덱스를 검토할 수 있다.

CREATE INDEX idx_shipments_status_created
ON shipments (status, created_at ASC);

이 인덱스는 특정 상태의 배송을 오래된 순서로 처리하는 운영 화면에 맞춰져 있다.

인덱스의 효과는 컬럼 타입이나 이름만으로 결정되지 않는다. 실제 값의 분포와 반환되는 데이터 양을 함께 확인해야 한다.


모든 배송보다 미처리 배송만 중요하다면 인덱스도 범위를 줄일 수 있다

운영자는 완료된 배송보다 아직 처리 중인 배송을 훨씬 자주 조회할 수 있다.

SELECT *
FROM shipments
WHERE status IN (
  'pending',
  'picked_up',
  'in_transit'
)
ORDER BY created_at ASC;

서비스가 오래 운영될수록 완료된 배송은 계속 쌓인다. 전체 데이터를 대상으로 큰 인덱스를 유지하는 대신 현재 운영에 필요한 일부 행만 인덱싱하는 방법을 검토할 수 있다.

CREATE INDEX idx_shipments_active_created
ON shipments (created_at ASC)
WHERE status IN (
  'pending',
  'picked_up',
  'in_transit'
);

이 부분 인덱스는 처리 중인 배송만 포함한다.

  • 완료된 배송은 인덱스 크기를 늘리지 않는다.
  • 운영 화면은 오래된 미처리 배송을 순서대로 찾을 수 있다.
  • 배송이 완료되면 해당 행은 인덱스의 대상에서 제외된다.

그러나 부분 인덱스는 정의된 조건과 실제 쿼리가 맞아야 효과를 얻을 수 있다.

다음처럼 모든 상태를 조회하는 화면에는 같은 인덱스가 충분하지 않다.

SELECT *
FROM shipments
WHERE customer_id = $1
ORDER BY created_at DESC;

부분 인덱스는 특정 쿼리에 강하게 맞춰진 선택이다. 읽기 패턴이 분명하지 않은 상태에서 추가하면 다른 조회에는 사용되지 않는 구조가 될 수 있다.

인덱스 범위를 줄이는 것은 단순한 최적화 기술이 아니다. 서비스에서 어떤 데이터가 현재 자주 읽히는지를 표현하는 설계다.


인덱스를 추가할 때마다 쓰기 작업도 함께 늘어난다

운송장 번호, 고객, 상태, 날짜, 우편번호에 인덱스를 모두 만든다고 생각해 보자.

CREATE INDEX idx_shipments_tracking_number
ON shipments (tracking_number);

CREATE INDEX idx_shipments_customer_id
ON shipments (customer_id);

CREATE INDEX idx_shipments_status
ON shipments (status);

CREATE INDEX idx_shipments_created_at
ON shipments (created_at);

CREATE INDEX idx_shipments_postcode
ON shipments (destination_postcode);

새로운 배송을 저장할 때 데이터베이스는 테이블 행만 추가하지 않는다.

shipments 테이블에 행 저장
        +
운송장 번호 인덱스 갱신
        +
고객 인덱스 갱신
        +
상태 인덱스 갱신
        +
생성 시각 인덱스 갱신
        +
우편번호 인덱스 갱신

배송 상태가 변경될 때는 상태가 포함된 인덱스도 수정해야 한다.

UPDATE shipments
SET status = 'delivered',
    delivered_at = NOW()
WHERE id = $1;

인덱스가 많아질수록 다음 비용이 증가할 수 있다.

  • 데이터 삽입과 수정에 필요한 작업
  • 인덱스를 저장하는 디스크 공간
  • 메모리에 유지해야 하는 데이터
  • 백업과 복구에 필요한 시간
  • 스키마 변경과 배포 시 관리해야 하는 구조
  • 사용되지 않는 인덱스를 점검하는 운영 비용

따라서 읽기 쿼리 하나가 빨라졌다는 사실만으로 인덱스가 무료인 것은 아니다.

선택얻는 것지불하는 것
인덱스를 추가함특정 조회 경로의 성능저장 공간과 쓰기 비용
복합 인덱스를 추가함조건과 정렬을 함께 처리할 가능성더 큰 인덱스와 제한된 재사용 범위
부분 인덱스를 추가함특정 데이터 집합의 작은 인덱스조건에 맞는 쿼리에서만 사용 가능
인덱스를 추가하지 않음단순한 쓰기와 적은 저장 공간데이터 증가에 따른 읽기 비용

인덱스 설계는 읽기 속도만 높이는 작업이 아니라 읽기와 쓰기 사이의 비용을 배분하는 작업이다.


고유 인덱스는 성능보다 서비스의 약속을 지킬 수 있다

배송 서비스에서 운송장 번호는 중복되면 안 된다고 가정해 보자.

애플리케이션에서 먼저 확인할 수 있다.

const existingShipment =
  await shipmentRepository.findByTrackingNumber(
    trackingNumber
  );

if (!existingShipment) {
  await shipmentRepository.create({
    trackingNumber,
    customerId,
  });
}

그러나 동시에 두 요청이 실행되면 둘 다 운송장 번호가 존재하지 않는다고 판단할 수 있다.

고유 제약 조건을 데이터베이스에 표현해야 한다.

ALTER TABLE shipments
ADD CONSTRAINT shipments_tracking_number_unique
UNIQUE (tracking_number);

데이터베이스는 이 제약을 보장하기 위해 고유한 인덱스 구조를 사용할 수 있다.

이 구조의 첫 번째 목적은 단순히 조회를 빠르게 만드는 것이 아니다.

하나의 운송장 번호는 하나의 배송만 식별한다는 서비스의 약속을 데이터베이스가 보장한다.

외부 배송사마다 같은 형식의 운송장 번호를 사용할 수 있다면 고유성 범위가 달라질 수 있다.

ALTER TABLE shipments
ADD CONSTRAINT shipments_carrier_tracking_unique
UNIQUE (carrier_code, tracking_number);

이제 운송장 번호는 배송사 안에서만 고유하면 된다.

인덱스와 제약 조건은 비슷한 내부 구조를 사용할 수 있지만 설계 의도는 구분해야 한다.

  • 일반 인덱스는 주로 조회 경로를 최적화한다.
  • 고유 제약 조건은 데이터가 지켜야 하는 규칙을 보장한다.

성능 요구가 바뀌면 일반 인덱스는 제거할 수 있다. 그러나 고유 제약을 제거하면 서비스의 데이터 규칙까지 바뀐다.


정렬과 페이지네이션도 인덱스 설계의 일부다

크리스가 고객의 배송 이력을 페이지 단위로 제공한다고 생각해 보자.

SELECT *
FROM shipments
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;

customer_id와 created_at을 포함한 인덱스는 필터링과 정렬에 도움을 줄 수 있다. 하지만 큰 OFFSET은 앞의 결과를 건너뛰기 위해 많은 항목을 확인하게 만들 수 있다.

마지막으로 확인한 위치를 기준으로 다음 페이지를 가져올 수 있다.

SELECT *
FROM shipments
WHERE customer_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 20;

이 조회에 맞춰 인덱스를 구성할 수 있다.

CREATE INDEX idx_shipments_customer_cursor
ON shipments (
  customer_id,
  created_at DESC,
  id DESC
);

id는 같은 created_at 값을 가진 여러 배송의 순서를 안정적으로 구분한다.

프론트엔드는 마지막 항목의 값을 다음 요청에 전달할 수 있다.

type ShipmentCursor = {
  createdAt: string;
  shipmentId: string;
};

이 커서는 새로운 Source of Truth가 아니다. 배송 데이터의 현재 정렬 위치를 표현하는 조회용 값이다.

페이지네이션 방법과 인덱스를 따로 설계하면 둘 중 하나가 다른 하나의 장점을 사용하지 못할 수 있다. 필터, 정렬, 페이지 경계를 하나의 조회 패턴으로 함께 봐야 한다.


인덱스가 있다는 사실보다 실행 계획이 중요하다

인덱스를 생성했다고 해서 데이터베이스가 반드시 사용하는 것은 아니다.

CREATE INDEX idx_shipments_status
ON shipments (status);

실제 실행 방식을 확인하려면 실행 계획을 살펴봐야 한다.

EXPLAIN ANALYZE
SELECT *
FROM shipments
WHERE status = 'delivered';

실행 계획을 통해 다음 내용을 확인할 수 있다.

  • 전체 테이블을 읽었는가?
  • 인덱스를 사용했는가?
  • 예상한 행 수와 실제 행 수가 비슷한가?
  • 정렬이 별도로 발생했는가?
  • 어느 단계에서 많은 시간과 데이터를 사용했는가?

인덱스가 사용되지 않는다고 해서 곧바로 문제가 있는 것은 아니다. 데이터가 적거나 조건에 맞는 행이 많다면 전체 테이블을 읽는 편이 더 나을 수 있다.

반대로 인덱스를 사용했다고 해서 쿼리가 충분히 빠르다는 뜻도 아니다. 너무 많은 행을 읽거나, 인덱스 사용 후 추가 정렬과 테이블 접근이 많이 발생할 수 있다.

성능 판단은 다음 순서로 진행하는 편이 낫다.

실제 느린 사용자 흐름 확인
        ↓
실행되는 쿼리 확인
        ↓
실행 계획과 읽은 행 수 확인
        ↓
후보 인덱스 설계
        ↓
변경 전후 측정
        ↓
쓰기 비용과 다른 쿼리 영향 확인

인덱스 이름이나 개수보다 중요한 것은 실제 작업량이 줄었는지 확인하는 일이다.


ORM이 관계를 표현해도 인덱스까지 자동으로 설계해 주지는 않는다

애플리케이션이 ORM을 사용하면 모델에 관계와 필드를 정의할 수 있다.

type Shipment = {
  id: string;
  customerId: string;
  trackingNumber: string;
  status: ShipmentStatus;
  createdAt: Date;
};

ORM을 통해 다음과 같은 조회를 작성할 수 있다.

const shipments =
  await shipmentRepository.findMany({
    where: {
      customerId,
      status: "in_transit",
    },
    orderBy: {
      createdAt: "desc",
    },
    take: 20,
  });

코드는 읽기 쉽지만 데이터베이스에서는 결국 조건과 정렬을 포함한 쿼리가 실행된다.

모델에 customerId, status, createdAt 필드가 존재한다고 해서 실제 조회에 필요한 복합 인덱스까지 자동으로 만들어지는 것은 아니다. 관계를 위한 외래 키도 데이터베이스나 ORM 설정에 따라 자동으로 인덱싱되지 않을 수 있다.

따라서 다음 내용을 직접 확인해야 한다.

  • ORM이 생성한 실제 SQL은 무엇인가?
  • 어떤 조건과 정렬이 사용되는가?
  • 마이그레이션에 필요한 인덱스가 포함되어 있는가?
  • 운영 데이터 규모에서 실행 계획은 어떻게 나오는가?
  • 쿼리 변경 후 기존 인덱스가 여전히 필요한가?

인덱스는 TypeScript 모델만 보고 설계할 수 없다. 실제 데이터베이스가 실행하는 쿼리와 데이터 분포를 기준으로 판단해야 한다.


검색 조건은 인덱스가 있어도 검증 전까지 신뢰할 수 없다

고객이 운송장 번호를 입력해 배송을 조회한다고 생각해 보자.

const shipment =
  await shipmentRepository.findByTrackingNumber(
    request.query.trackingNumber
  );

인덱스는 조회를 빠르게 만들 수 있지만 입력값이 올바른지는 확인하지 않는다. 외부 입력은 검증 전까지 신뢰할 수 없다.

import { z } from "zod";

const TrackingQuerySchema = z.object({
  trackingNumber: z
    .string()
    .trim()
    .min(8)
    .max(40)
    .regex(/^[A-Z0-9-]+$/),
});

const { trackingNumber } =
  TrackingQuerySchema.parse(request.query);

이 코드는 운송장 번호의 길이와 허용 문자를 제한한다.

운영자 검색 화면에서 정렬 필드를 받는 경우도 주의해야 한다.

const ShipmentSearchSchema = z.object({
  status: z
    .enum([
      "pending",
      "picked_up",
      "in_transit",
      "delivered",
      "cancelled",
    ])
    .optional(),
  sortBy: z
    .enum(["createdAt", "deliveredAt"])
    .default("createdAt"),
  limit: z.coerce.number().int().min(1).max(100),
});

허용된 정렬 필드와 결과 수를 제한하면 의도하지 않은 쿼리와 지나치게 큰 조회를 줄일 수 있다.

검증되지 않은 검색 기능은 다음 문제를 만들 수 있다.

  • 지나치게 넓은 기간을 조회한다.
  • 한 번에 과도한 데이터를 요청한다.
  • 인덱스와 맞지 않는 임의 정렬을 반복한다.
  • 존재 여부를 통해 다른 사용자의 배송 정보를 추측한다.
  • 같은 비싼 검색을 자동으로 반복한다.

인덱스는 보안이나 권한 검사를 대신하지 않는다. 조회가 빠르다는 사실과 사용자가 해당 데이터를 볼 수 있다는 사실은 별개의 문제다.

const shipment =
  await shipmentRepository.findOne({
    trackingNumber,
    customerId: currentUser.id,
  });

고객 전용 조회라면 운송장 번호뿐 아니라 현재 사용자의 범위도 함께 적용해야 한다.


인덱스는 원본 데이터가 아니라 원본을 찾기 위한 파생 구조다

배송의 Source of Truth는 shipments 테이블에 저장된 배송 데이터다.

UPDATE shipments
SET status = 'delivered',
    delivered_at = NOW()
WHERE id = $1;

인덱스는 이 데이터를 다른 순서와 형태로 탐색할 수 있도록 데이터베이스가 관리하는 파생 구조다.

Source of Truth
shipments의 행
        ↓
데이터베이스가 인덱스 갱신
        ↓
조회 시 후보 위치 탐색

애플리케이션이 일반적인 데이터 변경 과정에서 인덱스 값을 별도로 수정하지는 않는다. 테이블 데이터가 바뀌면 데이터베이스가 관련 인덱스를 함께 관리한다.

이 구분은 인덱스를 설계할 때 중요한 기준을 제공한다.

  • 인덱스를 제거해도 원본 배송 데이터는 남아 있어야 한다.
  • 인덱스는 원본 데이터로 다시 구성할 수 있어야 한다.
  • 인덱스 장애나 손상은 조회 성능과 접근에 영향을 줄 수 있지만 비즈니스 사실의 정의를 바꾸지는 않는다.
  • 조회 패턴이 바뀌면 인덱스도 변경하거나 제거할 수 있다.

인덱스를 서비스 데이터 자체처럼 다루기보다 특정 조회 경로를 위한 파생 구조로 이해해야 한다.


인덱스를 설계하기 전에 물어봐야 할 질문

어떤 조회를 빠르게 해야 하는가

  1. 실제로 느린 사용자 행동은 무엇인가?
  2. 그 행동에서 실행되는 SQL은 무엇인가?
  3. 필터 조건과 정렬 조건은 무엇인가?
  4. 한 번에 몇 개의 결과를 반환하는가?
  5. 조회 빈도와 허용 가능한 응답 시간은 어느 정도인가?

데이터가 얼마나 줄어드는가

  1. 각 조건은 전체 데이터 중 몇 개의 행을 남기는가?
  2. 값의 종류가 적은 컬럼을 단독으로 인덱싱하고 있지 않은가?
  3. 조건에 맞는 데이터가 테이블 대부분이라면 전체 조회가 더 나을 수 있는가?
  4. 시간이 지나면서 값의 분포가 달라질 수 있는가?
  5. 운영 데이터와 개발용 소량 데이터에서 결과가 다르지 않은가?

복합 인덱스의 순서가 쿼리와 맞는가

  1. 항상 사용되는 동등 조건은 무엇인가?
  2. 범위 조건은 어느 컬럼에 적용되는가?
  3. 결과는 어떤 순서로 정렬되는가?
  4. 페이지네이션 경계에는 어떤 컬럼이 필요한가?
  5. 같은 인덱스를 중요한 다른 쿼리에도 사용할 수 있는가?

읽기 성능과 쓰기 비용을 함께 봤는가

  1. 이 테이블에는 데이터가 얼마나 자주 추가되는가?
  2. 인덱스에 포함된 컬럼은 얼마나 자주 변경되는가?
  3. 비슷하거나 중복된 인덱스가 이미 존재하지 않는가?
  4. 인덱스 크기와 저장 공간을 감당할 수 있는가?
  5. 조회 개선이 삽입과 수정 비용 증가를 정당화하는가?

제약 조건과 성능 목적을 구분했는가

  1. 이 인덱스는 조회를 위한 것인가, 고유성 보장을 위한 것인가?
  2. 중복을 금지해야 하는 실제 비즈니스 규칙이 있는가?
  3. 고유성의 범위는 전체 서비스인가, 배송사나 고객 내부인가?
  4. 애플리케이션 검사와 데이터베이스 제약을 함께 사용하고 있는가?
  5. 인덱스를 제거할 때 데이터 규칙도 함께 제거되지 않는가?

실제 효과를 측정했는가

  1. 변경 전 실행 계획을 확인했는가?
  2. 예상 행 수와 실제 행 수가 비슷한가?
  3. 인덱스 추가 후 읽은 행과 실행 시간이 줄었는가?
  4. 별도 정렬이나 많은 테이블 접근이 남아 있지 않은가?
  5. 운영 중 사용되지 않는 인덱스를 확인할 방법이 있는가?

검증과 권한을 적용했는가

  1. 검색어의 형식과 길이를 검증하는가?
  2. 조회 기간과 결과 수에 제한이 있는가?
  3. 사용자가 선택할 수 있는 정렬 필드를 제한하는가?
  4. 인덱스가 있다는 이유로 비싼 검색을 무제한 허용하고 있지 않은가?
  5. 빠른 조회와 데이터 접근 권한을 별도로 검사하는가?

흔한 실수는 인덱스를 많을수록 좋은 성능 옵션으로 보는 것이다

WHERE에 등장하는 모든 컬럼에 인덱스를 만든다

컬럼이 조건에 사용된다는 사실만으로 인덱스가 필요한 것은 아니다.

실제 쿼리의 조건 조합, 정렬, 결과 수를 함께 확인해야 한다.

복합 인덱스의 컬럼 순서를 임의로 정한다

같은 컬럼을 포함하더라도 순서가 다르면 지원하는 조회 경로가 달라진다.

서비스가 데이터를 좁히고 정렬하는 순서에 맞춰 설계해야 한다.

데이터가 적은 개발 환경에서 성능을 판단한다

수백 건의 테스트 데이터에서는 전체 조회와 인덱스 조회의 차이가 거의 보이지 않을 수 있다.

운영 규모와 비슷한 데이터 양과 분포에서 실행 계획을 확인해야 한다.

인덱스가 있으면 데이터베이스가 반드시 사용한다고 생각한다

조건에 맞는 행이 많거나 테이블이 작다면 전체 테이블 조회가 더 저렴할 수 있다.

인덱스 존재 여부가 아니라 실행 계획을 확인해야 한다.

읽기 성능만 보고 쓰기 비용을 무시한다

인덱스는 데이터가 추가되고 변경될 때마다 함께 관리되어야 한다.

읽기 개선이 저장 공간과 쓰기 지연을 감당할 가치가 있는지 판단해야 한다.

ORM 모델만 보고 인덱스를 설계한다

애플리케이션 타입은 실제 SQL의 조건, 조인, 정렬을 모두 보여주지 않는다.

ORM이 생성한 SQL과 데이터베이스 실행 계획을 기준으로 판단해야 한다.

오래전에 만든 인덱스를 계속 유지한다

기능과 쿼리가 바뀌면 더 이상 사용되지 않는 인덱스가 남을 수 있다.

인덱스도 코드처럼 실제 사용 여부를 측정하고 정리해야 한다.


인덱스의 핵심은 읽기와 쓰기의 비용을 선택하는 데 있다

프로그래밍을 처음 배울 때는 인덱스를 책의 색인처럼 데이터를 빠르게 찾는 기능이라고 이해해도 충분하다.

CREATE INDEX idx_shipments_tracking_number
ON shipments (tracking_number);

하지만 실제 서비스에서 인덱스를 설계할 때는 속도라는 결과보다 어떤 비용을 교환하는지 먼저 살펴봐야 한다.

  • 어떤 사용자 행동과 쿼리를 빠르게 해야 하는가?
  • 조건과 정렬은 어떤 조합으로 사용되는가?
  • 각 조건이 검색 범위를 얼마나 줄이는가?
  • 복합 인덱스의 컬럼 순서가 조회 경로와 맞는가?
  • 페이지네이션 방식이 인덱스를 활용할 수 있는가?
  • 조회 성능을 위해 얼마만큼의 저장 공간을 사용하는가?
  • 데이터 삽입과 수정 비용이 얼마나 증가하는가?
  • 고유성 보장과 조회 최적화의 목적을 구분했는가?
  • 실제 실행 계획에서 작업량이 줄었는가?
  • 더 이상 사용되지 않는 인덱스를 정리하고 있는가?

인덱스는 검색을 빠르게 만드는 기능이 아니다.

인덱스는 자주 사용되는 조회 경로를 미리 구성하는 대신 저장 공간과 데이터 변경 비용을 지불하는 읽기 성능 설계다.

profile
Vision eXperience Developer

0개의 댓글