프로그래밍을 처음 배울 때 데이터베이스 인덱스는 보통 책의 색인처럼 원하는 데이터를 빠르게 찾도록 도와주는 기능이라고 배운다.
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';
인덱스를 사용하더라도 수많은 인덱스 항목과 테이블 행을 읽어야 한다. 데이터베이스는 전체 테이블을 순차적으로 읽는 편이 더 저렴하다고 판단할 수 있다.
인덱스가 효과적이려면 조건이 읽어야 할 후보를 의미 있게 줄이는 경우가 많아야 한다. 이를 판단할 때는 값의 선택도를 살펴볼 수 있다.
상태 조회가 중요하다면 상태만 보지 않고 실제 화면의 조건을 함께 인덱스에 반영할 수 있다.
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을 사용하면 모델에 관계와 필드를 정의할 수 있다.
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 설정에 따라 자동으로 인덱싱되지 않을 수 있다.
따라서 다음 내용을 직접 확인해야 한다.
인덱스는 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의 행
↓
데이터베이스가 인덱스 갱신
↓
조회 시 후보 위치 탐색
애플리케이션이 일반적인 데이터 변경 과정에서 인덱스 값을 별도로 수정하지는 않는다. 테이블 데이터가 바뀌면 데이터베이스가 관련 인덱스를 함께 관리한다.
이 구분은 인덱스를 설계할 때 중요한 기준을 제공한다.
인덱스를 서비스 데이터 자체처럼 다루기보다 특정 조회 경로를 위한 파생 구조로 이해해야 한다.
WHERE에 등장하는 모든 컬럼에 인덱스를 만든다컬럼이 조건에 사용된다는 사실만으로 인덱스가 필요한 것은 아니다.
실제 쿼리의 조건 조합, 정렬, 결과 수를 함께 확인해야 한다.
같은 컬럼을 포함하더라도 순서가 다르면 지원하는 조회 경로가 달라진다.
서비스가 데이터를 좁히고 정렬하는 순서에 맞춰 설계해야 한다.
수백 건의 테스트 데이터에서는 전체 조회와 인덱스 조회의 차이가 거의 보이지 않을 수 있다.
운영 규모와 비슷한 데이터 양과 분포에서 실행 계획을 확인해야 한다.
조건에 맞는 행이 많거나 테이블이 작다면 전체 테이블 조회가 더 저렴할 수 있다.
인덱스 존재 여부가 아니라 실행 계획을 확인해야 한다.
인덱스는 데이터가 추가되고 변경될 때마다 함께 관리되어야 한다.
읽기 개선이 저장 공간과 쓰기 지연을 감당할 가치가 있는지 판단해야 한다.
애플리케이션 타입은 실제 SQL의 조건, 조인, 정렬을 모두 보여주지 않는다.
ORM이 생성한 SQL과 데이터베이스 실행 계획을 기준으로 판단해야 한다.
기능과 쿼리가 바뀌면 더 이상 사용되지 않는 인덱스가 남을 수 있다.
인덱스도 코드처럼 실제 사용 여부를 측정하고 정리해야 한다.
프로그래밍을 처음 배울 때는 인덱스를 책의 색인처럼 데이터를 빠르게 찾는 기능이라고 이해해도 충분하다.
CREATE INDEX idx_shipments_tracking_number
ON shipments (tracking_number);
하지만 실제 서비스에서 인덱스를 설계할 때는 속도라는 결과보다 어떤 비용을 교환하는지 먼저 살펴봐야 한다.
인덱스는 검색을 빠르게 만드는 기능이 아니다.
인덱스는 자주 사용되는 조회 경로를 미리 구성하는 대신 저장 공간과 데이터 변경 비용을 지불하는 읽기 성능 설계다.