Claude Code로 SQL 최적화하기: 느린 쿼리부터 배포 점검까지
Claude Code로 SQL 최적화를 진행하는 방법: 느린 쿼리, EXPLAIN, 인덱스, N+1, 페이지네이션, 테스트.
SQL 최적화는 무엇을 고치는 일인가
SQL 최적화는 같은 결과를 더 적은 읽기와 더 짧은 대기 시간으로 반환하도록 쿼리, 인덱스, 데이터 접근 방식을 정리하는 작업입니다. SQL 문장 하나를 멋지게 줄이는 일이 아니라, 애플리케이션 엔드포인트, ORM 호출, 실행 계획, 실제 데이터량, 회귀 테스트, 배포 후 모니터링을 함께 보는 일입니다.
Claude Code가 유용한 이유는 SQL 문자열 하나가 아니라 라우트 핸들러, Prisma 같은 ORM 모델, 마이그레이션, 느린 쿼리 로그, 테스트를 한 번에 읽게 할 수 있기 때문입니다. 실제 서비스의 지연은 대부분 하나의 어려운 쿼리보다 작은 문제들의 누적에서 옵니다. 목록 화면의 SELECT *, N+1 루프, 깊은 OFFSET 페이지네이션, 쿼리와 맞지 않는 복합 인덱스, 매 요청마다 동기 실행되는 대시보드 집계가 대표적입니다.
이 글은 PostgreSQL을 중심으로 설명하고 MySQL도 함께 고려합니다. 실행 계획을 볼 때는 공식 문서인 PostgreSQL Using EXPLAIN, PostgreSQL EXPLAIN, PostgreSQL Indexes, PostgreSQL Multicolumn Indexes, MySQL 8.4 Using EXPLAIN을 같이 확인하세요.
관련 주제로는 데이터베이스 설계, 데이터베이스 마이그레이션, Prisma ORM, 성능 최적화를 함께 보면 좋습니다.
먼저 사용 사례를 나눈다
화면과 업무 흐름을 먼저 정하면 Claude Code의 작업이 구체적이 됩니다.
| 사용 사례 | 증상 | 주요 개선 | 주의점 |
|---|---|---|---|
| 관리자 목록 | 검색과 깊은 페이지가 느림 | 복합 인덱스, 키셋 페이지네이션, 조회 컬럼 축소 | 필터 조합이 많으면 인덱스가 과도해짐 |
| SaaS 또는 커머스 화면 | 트래픽 증가 후 목록 API가 느림 | WHERE / ORDER BY에 맞는 인덱스, 캐시, 비동기 집계 | 재고, 테넌트, 권한 조건을 빼면 사고가 남 |
| 대시보드 | 월별/일별 차트가 큰 테이블을 매번 스캔 | 집계 테이블, 머티리얼라이즈, 예약 갱신 | 데이터 신선도 요구를 먼저 정해야 함 |
수정 전에 쿼리 예산을 잡습니다. 예를 들어 p95 300ms 이하, 목록 요청당 SQL 5개 이하, 응답 JSON 호환 유지처럼 정량화합니다.
Claude Code 프롬프트:
이 목록 API의 SQL 성능을 최적화해 주세요.
목표: p95 300ms 이하, SQL 5개 이하, 응답 JSON 변경 없음.
검토 파일: src/routes/admin/orders.ts, prisma/schema.prisma, migrations, slow-query.log.
작업: 느린 SQL 식별, EXPLAIN ANALYZE 해석, 인덱스 제안, N+1 제거, 회귀 테스트 추가.
제약: 변경 범위를 작게 유지하고 운영 호환성을 지킬 것.
1단계: 느린 쿼리를 잡는다
기억에 의존해서 인덱스를 만들지 마세요. 실제로 느린 SQL을 먼저 잡아야 합니다. PostgreSQL에서는 pg_stat_statements나 DB 로그를 사용할 수 있고, 애플리케이션의 ORM 로그도 SQL과 호출 위치를 연결하는 데 유용합니다.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
query,
calls,
ROUND(total_exec_time::numeric, 2) AS total_ms,
ROUND(mean_exec_time::numeric, 2) AS mean_ms,
rows
FROM pg_stat_statements
WHERE query NOT ILIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 10;
Prisma에서는 실제 Prisma Client API로 개발 환경에서 쿼리 로그를 남길 수 있습니다.
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: [
{ emit: "event", level: "query" },
{ emit: "stdout", level: "error" },
{ emit: "stdout", level: "warn" },
],
});
prisma.$on("query", (event) => {
if (event.duration > 100) {
console.log({
durationMs: event.duration,
query: event.query,
params: event.params,
});
}
});
로그를 Claude Code에 줄 때는 이메일, 토큰, 고객 ID를 마스킹하세요. 그다음 화면, 호출 위치, 평균 시간, 호출 횟수, 의심 원인별로 묶어 달라고 요청합니다.
2단계: EXPLAIN과 EXPLAIN ANALYZE 읽기
후보 SQL을 찾았으면 EXPLAIN으로 계획을 확인합니다. 운영과 비슷한 데이터가 있는 안전한 환경에서는 EXPLAIN (ANALYZE, BUFFERS)를 사용합니다. 단, ANALYZE는 SQL을 실제로 실행하므로 UPDATE나 DELETE에는 신중해야 합니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT
o.id,
o.created_at,
o.status,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
AND o.created_at >= DATE '2026-01-01'
ORDER BY o.created_at DESC, o.id DESC
LIMIT 50;
Seq Scan이 보인다고 무조건 나쁜 것은 아닙니다. 작은 테이블이나 선택도가 낮은 조건에서는 순차 스캔이 더 합리적일 수 있습니다. 중요한 것은 예상 행 수와 실제 행 수의 차이, 큰 정렬, 과도한 버퍼 읽기, 예상과 다른 JOIN 순서입니다.
Claude Code 프롬프트:
이 EXPLAIN ANALYZE를 병목, 행 수 추정 오차, 인덱스 후보, 안전한 SQL 재작성 후보로 나누어 리뷰해 주세요. 결과 집합은 바꾸지 마세요.
3단계: 복합 인덱스 설계
인덱스는 읽기를 빠르게 하지만 쓰기 비용, 저장 공간, 마이그레이션 위험을 늘립니다. 실제 쿼리를 기준으로 등가 조건, 범위 조건, 정렬 컬럼, JOIN 요구 순서로 설계합니다.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status_created_id
ON orders (status, created_at DESC, id DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_created
ON orders (customer_id, created_at DESC);
PostgreSQL의 CREATE INDEX CONCURRENTLY는 쓰기 차단을 줄이지만 일반 트랜잭션 블록 안에서는 실행할 수 없습니다. 사용하는 마이그레이션 도구가 이를 어떻게 처리하는지 PR에 명확히 적어야 합니다.
Claude Code에 인덱스를 제안하게 하기 전에 기존 인덱스를 함께 전달하세요.
SELECT
indexname,
indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'orders'
ORDER BY indexname;
4단계: N+1 제거
N+1은 목록을 가져온 뒤 각 행마다 관련 데이터를 다시 조회하는 문제입니다. DB 작업량뿐 아니라 네트워크 대기 시간도 늘어납니다.
// 나쁜 예: 주문마다 고객을 별도 조회
const orders = await prisma.order.findMany({
take: 100,
orderBy: { createdAt: "desc" },
});
const rows = [];
for (const order of orders) {
const customer = await prisma.customer.findUnique({
where: { id: order.customerId },
});
rows.push({ ...order, customerName: customer?.name ?? "" });
}
// 개선: UI에 필요한 컬럼만 한 번에 조회
const orders = await prisma.order.findMany({
take: 100,
orderBy: { createdAt: "desc" },
select: {
id: true,
createdAt: true,
status: true,
totalAmount: true,
customer: {
select: {
id: true,
name: true,
},
},
},
});
Claude Code에는 응답 형태를 유지하면서 SQL 개수를 줄이라고 요청하세요. 그러면 UI나 직렬화 코드를 읽고 필요한 컬럼만 남기기 쉽습니다.
5단계: 깊은 OFFSET을 키셋 페이지네이션으로 바꾸기
OFFSET 10000 LIMIT 50은 앞의 10000개를 건너뛰어야 하므로 뒤로 갈수록 느려집니다. 시간순 목록이나 무한 스크롤에서는 마지막으로 본 created_at과 id를 커서로 쓰는 키셋 페이지네이션이 적합합니다.
SELECT id, created_at, status, total_amount
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50;
SELECT id, created_at, status, total_amount
FROM orders
WHERE status = 'paid'
AND (created_at, id) < (TIMESTAMP '2026-05-01 10:30:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 50;
이 접근에는 (status, created_at DESC, id DESC) 인덱스가 잘 맞습니다. 같은 시간이 여러 건일 수 있으므로 id를 타이브레이커로 넣어 정렬을 안정화합니다.
6단계: 집계를 뜨거운 요청에서 분리
대시보드는 매 요청마다 같은 합계를 다시 계산해서 느려지는 경우가 많습니다. 업무적으로 지연을 허용할 수 있다면 집계 테이블이나 예약 갱신으로 분리합니다.
CREATE TABLE IF NOT EXISTS daily_sales_summary (
sales_date date PRIMARY KEY,
order_count integer NOT NULL,
revenue numeric(12, 2) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO daily_sales_summary (sales_date, order_count, revenue)
SELECT
created_at::date AS sales_date,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid'
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY created_at::date
ON CONFLICT (sales_date) DO UPDATE
SET
order_count = EXCLUDED.order_count,
revenue = EXCLUDED.revenue,
updated_at = now();
Claude Code에는 데이터가 몇 분 늦어져도 되는지 명시해야 합니다. 그렇지 않으면 성능 개선이 대시보드의 의미를 바꿀 수 있습니다.
쿼리 예산과 회귀 테스트
최적화는 다시 나빠지기 쉽습니다. 실행 계획 전체를 테스트로 고정하면 취약하므로, 먼저 SQL 개수, 응답 호환성, 대표 케이스를 검증합니다.
import { PrismaClient } from "@prisma/client";
import { expect, it } from "vitest";
it("keeps the order list within the query budget", async () => {
const queries: string[] = [];
const prisma = new PrismaClient({
log: [{ emit: "event", level: "query" }],
});
prisma.$on("query", (event) => {
queries.push(event.query);
});
await prisma.order.findMany({
take: 50,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: {
id: true,
createdAt: true,
status: true,
customer: { select: { id: true, name: true } },
},
});
await prisma.$disconnect();
expect(queries.length).toBeLessThanOrEqual(2);
});
흐름도
flowchart LR
A["느린 쿼리 로그"] --> B["EXPLAIN / EXPLAIN ANALYZE"]
B --> C["인덱스와 SQL 변경"]
C --> D["N+1, 페이지네이션, 집계 수정"]
D --> E["쿼리 예산 테스트"]
E --> F["배포와 모니터링"]
자주 나오는 함정
- 목록 화면에서
SELECT *로 큰 컬럼까지 읽는다. status처럼 값 종류가 적은 컬럼 단독 인덱스에 과도한 기대를 한다.- 일반 B-tree 인덱스로
LIKE '%keyword%'를 빠르게 만들려 한다. - 작은 개발 데이터로 운영 실행 계획을 판단한다.
- 피크 시간에 인덱스 마이그레이션을 실행하면서 잠금, 디스크, 레플리카 지연을 확인하지 않는다.
- ORM
include로 관계 그래프 전체를 반환한다.
배포 체크리스트
1. 대상 엔드포인트와 화면을 특정했는가
2. 로그 또는 pg_stat_statements로 느린 SQL을 확인했는가
3. 개선 전후 EXPLAIN을 저장했는가
4. 기존 인덱스와 중복되지 않는가
5. N+1, OFFSET, 조회 컬럼, 동기 집계를 확인했는가
6. 응답 JSON 호환성을 테스트했는가
7. SQL 개수 또는 지연 시간 회귀 테스트를 추가했는가
8. 인덱스 변경의 잠금 위험, 소요 시간, 롤백 절차를 문서화했는가
9. 배포 후 확인할 지표를 정했는가
SQL 최적화는 한 줄짜리 팁이 아니라 반복 가능한 워크플로입니다. Claude Code는 코드, 마이그레이션, 테스트를 연결해 주지만 최종 판단은 실제 데이터, 실행 계획, 제품 요구사항에 기반해야 합니다.
ClaudeCodeLab에서는 이런 실무형 리뷰를 바탕으로 교육과 상담을 제공합니다. 느린 엔드포인트, 로그, 스키마를 가져오면 팀이 반복해서 쓸 수 있는 SQL 최적화 체크리스트로 정리할 수 있습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.