데이터베이스 쿼리 최적화
요청 모양과 실제 SQL을 연결해 N+1·인덱스·projection·pagination 후보를 고르고 실행 계획과 canary 지표로 검증합니다.
NestJS 애플리케이션의 응답 지연은 컨트롤러보다 데이터베이스 왕복, 읽은 row 수, 정렬·조인 비용에서 시작되는 경우가 많습니다. 최적화는 먼저 느린 endpoint와 SQL을 연결하고, 가장 작은 변경을 적용한 뒤 같은 조건의 전후 지표로 채택 여부를 결정하는 작업입니다.
아래 다이어그램은 같은 목록 요청이 TypeORM을 거쳐 PostgreSQL에서 1 + N SQL이 되는 경로와, join 또는 batch로 왕복 수를 제한하는 경로를 비교합니다.
N+1과 bounded 관계 조회의 paired sequence
bounded batch 발췌 한 경로에서 부모 조회→빈 목록/IN 판정→원 반환 자료를 추적합니다. SQL 수는 원문 전제의 조건부 1·2이고 그룹화·DTO 결합은 추가 구현이며 N+1·join은 독립 비교안으로 원 표에 보존합니다.
Nest · TypeORM query comparison
목록 조회의 N+1과 bounded batch
목록 조회 뒤 항목마다 관계를 읽는 경로와 IN batch 경로를 비교합니다. join은 목록·관계를 함께 조회하는 별도 선택이며, 실제 SQL 왕복 수와 행 증폭은 로그·대표 데이터로 확인합니다.
| 관측한 증상 | 먼저 검토할 선택 | 반드시 확인할 근거 |
|---|---|---|
| 같은 relation SQL 반복 | 명시적 join 또는 IN batch | 왕복 수와 join 행 증폭 |
| filter·sort의 읽기 비용 증가 | 실제 predicate와 정렬을 반영한 index 후보 | 실행 계획, 데이터 분포, 쓰기 비용 |
| 넓은 row와 큰 응답 payload | select projection | 필드 계약과 전송량 |
| 무제한 목록 또는 불안정한 페이지 | take와 고유 tie-breaker 정렬 | 페이지 중복·누락과 deep offset 비용 |
GET /users · relation 포함 · page limit
항목마다 relation SQL 반복
목록 SQL 한 번 뒤 사용자마다 주문 SQL을 실행하면 데이터 개수에 따라 DB 왕복이 늘어납니다.
join 또는 IN batch
관계가 필요한 범위를 묶되 join 행 증폭과 batch 추가 왕복을 실제 데이터로 비교합니다.
filter·sort 비용
predicate, 정렬, 데이터 분포에 맞는 후보를 실행 계획과 쓰기 비용으로 검증합니다.
넓은 row·payload
필요한 필드만 projection하고 응답 계약과 전송량을 확인합니다.
무제한·불안정 목록
limit과 고유 tie-breaker 정렬을 두고 중복·누락과 deep offset 비용을 확인합니다.
목록 항목마다 반복되는 관계 SQL은 N+1의 증거입니다. index, projection, pagination은 서로 대체재가 아니라 각각 scan 비용, row 폭, 결과 집합 크기를 줄이는 선택이며 전후 측정으로 채택합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- HTTP 목록 요청을 TypeORM이 SQL로 바꾸고 PostgreSQL이 실행한다. 관계를 사용자마다 조회하면 N개의 추가 SQL이 반복되고, 필요한 관계를 join 또는 IN batch로 묶으면 왕복 수가 제한된다.
- 동일 HTTP 목록 요청과 TypeORM 부모 조회 1개 뒤 사용자별 관계 조회 또는 IN batch를 선택한다. 왼쪽은 N개의 관계 SQL이 추가되는 1+N, 오른쪽은 비어 있지 않은 목록에서 관계 SQL을 1개로 묶는 조건의 2왕복이다. 빈 목록은 batch 관계 조회 전에 반환한다. join은 별도 대안이며 pagination 등에 따른 실제 SQL 수와 행 증폭은 로그로 검증한다. 결과 결합은 본문 batch 발췌에서 추가로 구현할 단계다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | N+1과 bounded 관계 조회의 paired sequence |
| 47 | 동일 HTTP 목록 요청과 TypeORM 부모 조회 1개 뒤 사용자별 관계 조회 또는 IN batch를 선택한다. 왼쪽은 N개의 관계 SQL이 추가되는 1+N, 오른쪽은 비어 있지 않은 목록에서 관계 SQL을 1개로 묶는 조건의 2왕복이다. 빈 목록은 batch 관계 조회 전에 반환한다. join은 별도 대안이며 pagination 등에 따른 실제 SQL 수와 행 증폭은 로그로 검증한다. 결과 결합은 본문 batch 발췌에서 추가로 구현할 단계다. |
| 57 | 합계 1+N |
| 60 | 관계 1개 |
| 61 | 합계 2개 |
| 62 | 빈 목록 1개 |
| 66 | 왕복 · 행 증폭 · 지연 |
| 87 | limit과 고유 tie-breaker 정렬을 두고 중복·누락과 deep offset 비용을 검증합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| batch의 부모 목록 조회 bounded batch 발췌가 take 20과 id 오름차순으로 사용자 목록을 먼저 읽습니다. | 선택 경로: bounded batch 발췌 · 첫 조회: 부모 SQL 1개 · 계산 전제: 각 repository 호출이 SQL 하나, 추가 eager 조회 없음 | 부모 결과의 길이가 관계 SQL을 실행할지 결정합니다. N+1과 join은 이 실행의 앞뒤 단계가 아니라 원 표의 별도 비교안입니다. |
| 빈 목록과 IN 조회 판정 부모가 비면 즉시 []로 끝내고, 부모가 있으면 userId IN 조건으로 주문을 한 번 읽습니다. | 부모 없음: 관계 SQL 생략·총 1개 · 부모 있음: IN 주문 SQL 1개·총 2개 · N+1 비교: 원래 조건에서 1+N, 20명은 21개 | 같은 판정 지점에서 빈 목록은 종료하고 비어 있지 않은 목록만 관계 조회로 진행합니다. join의 실제 SQL 수는 별도 로그 확인 대상입니다. |
| 발췌의 반환 범위 빈 목록은 []를, 비어 있지 않은 목록은 { users, orders }를 반환합니다. | 빈 목록 결과: [] · 비어 있지 않은 결과: 별도 users·orders 자료 · 추가 구현: userId 그룹화·사용자 DTO 결합 · join 경계: 행 증폭·ORM pagination 조건을 따로 확인 | 두 자료를 얻은 상태까지가 발췌의 산출물입니다. 사용자별 관계 DTO를 이미 결합했다고 확대하지 않습니다. |
N+1과 bounded 관계 조회의 paired sequence
bounded batch 발췌 한 경로에서 부모 조회→빈 목록/IN 판정→원 반환 자료를 추적합니다. SQL 수는 원문 전제의 조건부 1·2이고 그룹화·DTO 결합은 추가 구현이며 N+1·join은 독립 비교안으로 원 표에 보존합니다.
Nest · TypeORM query comparison
목록 조회의 N+1과 bounded batch
목록 조회 뒤 항목마다 관계를 읽는 경로와 IN batch 경로를 비교합니다. join은 목록·관계를 함께 조회하는 별도 선택이며, 실제 SQL 왕복 수와 행 증폭은 로그·대표 데이터로 확인합니다.
| 관측한 증상 | 먼저 검토할 선택 | 반드시 확인할 근거 |
|---|---|---|
| 같은 relation SQL 반복 | 명시적 join 또는 IN batch | 왕복 수와 join 행 증폭 |
| filter·sort의 읽기 비용 증가 | 실제 predicate와 정렬을 반영한 index 후보 | 실행 계획, 데이터 분포, 쓰기 비용 |
| 넓은 row와 큰 응답 payload | select projection | 필드 계약과 전송량 |
| 무제한 목록 또는 불안정한 페이지 | take와 고유 tie-breaker 정렬 | 페이지 중복·누락과 deep offset 비용 |
GET /users · relation 포함 · page limit
항목마다 relation SQL 반복
목록 SQL 한 번 뒤 사용자마다 주문 SQL을 실행하면 데이터 개수에 따라 DB 왕복이 늘어납니다.
join 또는 IN batch
관계가 필요한 범위를 묶되 join 행 증폭과 batch 추가 왕복을 실제 데이터로 비교합니다.
filter·sort 비용
predicate, 정렬, 데이터 분포에 맞는 후보를 실행 계획과 쓰기 비용으로 검증합니다.
넓은 row·payload
필요한 필드만 projection하고 응답 계약과 전송량을 확인합니다.
무제한·불안정 목록
limit과 고유 tie-breaker 정렬을 두고 중복·누락과 deep offset 비용을 확인합니다.
목록 항목마다 반복되는 관계 SQL은 N+1의 증거입니다. index, projection, pagination은 서로 대체재가 아니라 각각 scan 비용, row 폭, 결과 집합 크기를 줄이는 선택이며 전후 측정으로 채택합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- HTTP 목록 요청을 TypeORM이 SQL로 바꾸고 PostgreSQL이 실행한다. 관계를 사용자마다 조회하면 N개의 추가 SQL이 반복되고, 필요한 관계를 join 또는 IN batch로 묶으면 왕복 수가 제한된다.
- 동일 HTTP 목록 요청과 TypeORM 부모 조회 1개 뒤 사용자별 관계 조회 또는 IN batch를 선택한다. 왼쪽은 N개의 관계 SQL이 추가되는 1+N, 오른쪽은 비어 있지 않은 목록에서 관계 SQL을 1개로 묶는 조건의 2왕복이다. 빈 목록은 batch 관계 조회 전에 반환한다. join은 별도 대안이며 pagination 등에 따른 실제 SQL 수와 행 증폭은 로그로 검증한다. 결과 결합은 본문 batch 발췌에서 추가로 구현할 단계다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | N+1과 bounded 관계 조회의 paired sequence |
| 47 | 동일 HTTP 목록 요청과 TypeORM 부모 조회 1개 뒤 사용자별 관계 조회 또는 IN batch를 선택한다. 왼쪽은 N개의 관계 SQL이 추가되는 1+N, 오른쪽은 비어 있지 않은 목록에서 관계 SQL을 1개로 묶는 조건의 2왕복이다. 빈 목록은 batch 관계 조회 전에 반환한다. join은 별도 대안이며 pagination 등에 따른 실제 SQL 수와 행 증폭은 로그로 검증한다. 결과 결합은 본문 batch 발췌에서 추가로 구현할 단계다. |
| 57 | 합계 1+N |
| 60 | 관계 1개 |
| 61 | 합계 2개 |
| 62 | 빈 목록 1개 |
| 66 | 왕복 · 행 증폭 · 지연 |
| 87 | limit과 고유 tie-breaker 정렬을 두고 중복·누락과 deep offset 비용을 검증합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| batch의 부모 목록 조회 bounded batch 발췌가 take 20과 id 오름차순으로 사용자 목록을 먼저 읽습니다. | 선택 경로: bounded batch 발췌 · 첫 조회: 부모 SQL 1개 · 계산 전제: 각 repository 호출이 SQL 하나, 추가 eager 조회 없음 | 부모 결과의 길이가 관계 SQL을 실행할지 결정합니다. N+1과 join은 이 실행의 앞뒤 단계가 아니라 원 표의 별도 비교안입니다. |
| 빈 목록과 IN 조회 판정 부모가 비면 즉시 []로 끝내고, 부모가 있으면 userId IN 조건으로 주문을 한 번 읽습니다. | 부모 없음: 관계 SQL 생략·총 1개 · 부모 있음: IN 주문 SQL 1개·총 2개 · N+1 비교: 원래 조건에서 1+N, 20명은 21개 | 같은 판정 지점에서 빈 목록은 종료하고 비어 있지 않은 목록만 관계 조회로 진행합니다. join의 실제 SQL 수는 별도 로그 확인 대상입니다. |
| 발췌의 반환 범위 빈 목록은 []를, 비어 있지 않은 목록은 { users, orders }를 반환합니다. | 빈 목록 결과: [] · 비어 있지 않은 결과: 별도 users·orders 자료 · 추가 구현: userId 그룹화·사용자 DTO 결합 · join 경계: 행 증폭·ORM pagination 조건을 따로 확인 | 두 자료를 얻은 상태까지가 발췌의 산출물입니다. 사용자별 관계 DTO를 이미 결합했다고 확대하지 않습니다. |
요청 모양과 SQL을 먼저 연결하기
다음 항목을 같은 관측 구간에 남깁니다.
- endpoint와 주요 query parameter
- p50·p95·p99 응답 시간
- 요청당 SQL 개수와 SQL fingerprint
- 반환 row와 읽은 row의 차이
- DB CPU·I/O·lock wait·connection wait
ORM 로그만 보면 어떤 사용자 요청이 SQL을 만들었는지 놓치기 쉽고, endpoint 지표만 보면 어느 SQL이 느린지 알기 어렵습니다. 요청 ID나 trace ID로 두 기록을 연결하면 최적화 대상을 구체적으로 고를 수 있습니다.
N+1: 데이터 개수와 함께 늘어나는 왕복
목록 SQL 한 번 뒤 각 사용자마다 관계 SQL을 실행하면 요청당 쿼리 수는 1 + N이 됩니다.
// 설명을 위한 N+1 예시
async function getUsersWithNPlusOne() {
const users = await this.userRepository.find({
take: 20,
order: { id: 'ASC' },
});
return Promise.all(
users.map(async user => ({
...user,
orders: await this.orderRepository.find({
where: { userId: user.id },
}),
})),
);
}예시 동시에 실행해도 SQL 개수는 줄어들지 않는다
다른 eager 조회나 후속 쿼리가 없고 각 repository 호출이 SQL 하나를 실행한다고 가정합니다. 목록이 20명을 반환하면 첫 SQL 1개와 사용자별 주문 SQL 20개를 더해 총 21개입니다. Promise.all()은 주문 요청을 함께 시작할 수 있게 하지만, 20개의 요청을 한 SQL로 합치지는 않습니다.
해결 목표는 무조건 SQL 한 번이 아니라 요청 크기가 늘어도 왕복 횟수가 제한되도록 만드는 것입니다.
관계가 항상 필요한 화면: 명시적 join
async function getUsersWithOrders() {
return this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.orderBy('user.id', 'ASC')
.take(20)
.getMany();
}join은 왕복을 줄이지만 일대다 관계가 크면 같은 부모 row가 반복되는 행 증폭과 메모리 사용이 커질 수 있습니다. 실제 관계 cardinality와 실행 계획을 보고 선택합니다.
관계가 선택적이거나 큰 화면: bounded batch
import { In } from 'typeorm';
async function getUsersWithOrderBatch() {
const users = await this.userRepository.find({
take: 20,
order: { id: 'ASC' },
});
if (users.length === 0) {
return [];
}
const orders = await this.orderRepository.findBy({
userId: In(users.map(user => user.id)),
});
// 실제 코드에서는 userId로 orders를 그룹화해 사용자 DTO에 결합합니다.
return { users, orders };
}이 방식은 부모 조회와 관계 조회를 분리하지만 요청당 왕복 수를 두 번처럼 제한할 수 있습니다. join의 행 증폭과 batch의 추가 왕복 중 어느 쪽이 나은지는 데이터 분포와 화면 계약으로 판단합니다.
직접 판단하기 목록이 5명을 반환했고 각 repository 호출은 위의 조건처럼 SQL 한 개라고 가정합니다. N+1 코드와 bounded batch 코드의 SQL 수는 각각 몇 개일까요? 목록이 비어 있으면 batch 코드가 주문 SQL도 실행할까요? 위의 join 코드는 같은 숫자를 항상 보장한다고 말할 수 있을까요?
호출 흐름으로 계산하기
N+1은 1 + 5 = 6개입니다. batch는 사용자 목록 1개와 IN 주문 조회 1개로 2개입니다. 빈 목록이면 users.length === 0에서 반환하므로 목록 SQL 1개만 실행합니다. join 코드는 ORM의 pagination 처리·관계 설정·DB 드라이버에 따라 추가 SQL이 생길 수 있으므로 실제 로그를 확인해야 합니다. 이 계산은 주어진 repository 호출 조건의 비교이며 모든 ORM 호출의 보편적 SQL 수가 아닙니다.
TypeORM 관계 로딩의 현재 의미
relations: { orders: true }는find*계열 조회에서 필요한 관계를 명시합니다.eager: true관계는find*계열에서 자동으로 로드됩니다.QueryBuilder에서는 eager 관계에 기대지 말고leftJoinAndSelect처럼 필요한 join을 명시합니다.eager: false는 lazy loading을 뜻하지 않습니다. TypeORM의 lazy relation은Promise타입을 사용하는 별도 설정이며 공식 문서에서도 실험적 기법으로 안내됩니다.
class User {
@OneToMany(() => Order, order => order.user, { lazy: true })
orders: Promise<Order[]>;
}lazy relation을 반복문에서 await하면 N+1을 만들기 쉬우므로 SQL 로그에서 실제 왕복을 확인합니다.
인덱스: 쿼리와 데이터에서 후보를 만든다
인덱스는 WHERE, JOIN, ORDER BY에 자주 쓰인다는 이유만으로 추가하지 않습니다. predicate, 정렬 방향, 데이터 분포, 읽기·쓰기 비율을 함께 보고 실행 계획으로 검증합니다.
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
@Index('IDX_user_last_first', ['lastName', 'firstName'])
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column({ unique: true })
email: string;
}이 예시는 email의 unique 선언을 컬럼에 한 번만 둡니다. 같은 unique 의미를 @Index(..., { unique: true })와 @Column({ unique: true })로 중복 선언하지 않습니다.
복합 인덱스의 컬럼 순서는 절대 규칙으로 정할 수 없습니다. leading column을 단독으로 쓰는 조회, equality·range 조건의 조합, 정렬, 선택도와 DB planner가 실제로 만든 계획을 함께 봅니다.
Projection: 현재 FindOptions 객체 형태 사용
필요한 필드만 반환하면 row 폭, 네트워크 전송, 직렬화 비용을 줄일 수 있습니다. 현재 TypeORM FindOptions의 select는 필드별 boolean 객체 형태로 작성할 수 있습니다.
const users = await this.userRepository.find({
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
order: {
lastName: 'ASC',
id: 'ASC',
},
take: 20,
});projection은 API 응답 계약과 함께 관리합니다. 뒤에서 relation이나 serializer가 선택하지 않은 필드가 당연히 존재한다고 가정하지 않도록 테스트합니다.
Pagination: 크기 제한과 고유 tie-breaker
offset pagination은 skip과 take로 표현할 수 있습니다. 결과가 같은 정렬 값에서 흔들리지 않도록 id처럼 고유한 tie-breaker를 마지막에 둡니다.
async function getUsersPage(page: number, pageSize: number) {
const skip = Math.max(0, page - 1) * pageSize;
const [rows, total] = await this.userRepository.findAndCount({
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
order: {
lastName: 'ASC',
id: 'ASC',
},
skip,
take: pageSize,
});
return { rows, total };
}이 함수는 HTTP 입력 검증을 마친 양의 정수 page와 제한된 양의 정수 pageSize를 받는 부분 예제입니다. Math.max()는 음수 offset을 막을 뿐 소수·0 크기·지나치게 큰 pageSize를 거절하는 DTO가 아닙니다. controller 경계에서 정수 여부, page >= 1, 팀이 정한 최대 pageSize를 확인해야 합니다.
findAndCount는 page rows와 count를 함께 반환하는 편의 API입니다. 두 결과가 항상 하나의 동시 snapshot이라고 가정하지 않습니다. 정확히 같은 snapshot이 업무 요구라면 사용하는 데이터베이스와 isolation level에 맞는 transaction 경계를 별도로 설계합니다.
deep offset 비용이나 목록 변경 중 중복·누락이 중요하면 고유 정렬 키를 기준으로 cursor pagination을 검토합니다.
Transaction은 필요한 DB 작업만 짧게
transaction은 일관성을 제공하지만 외부 API 호출, 긴 계산, 사용자 대기를 안에 넣으면 lock 보유 시간이 늘어날 수 있습니다. isolation level과 lock 동작은 데이터베이스와 driver 설정에 따라 다르므로 기본값을 추측하지 말고 실제 구성을 확인합니다.
실행 계획과 전후 지표로 검증하기
TypeORM logging은 개발·검증 환경에서 SQL과 반복 패턴을 찾는 데 유용합니다.
TypeOrmModule.forRoot({
// ...
logging: ['query', 'error'],
});PostgreSQL의 EXPLAIN은 planner의 추정 계획을 보여 줍니다.
EXPLAIN
SELECT id, email
FROM users
WHERE email = '[email protected]';실제 row와 buffer를 확인하려면 안전한 검증 환경에서 다음처럼 실행할 수 있습니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email
FROM users
WHERE email = '[email protected]';ANALYZE 옵션은 문장을 실제로 실행하고 측정 overhead도 추가합니다. INSERT, UPDATE, DELETE 같은 변경 문장은 부작용이 발생하므로 운영에서 무심코 실행하지 않습니다. 필요한 경우 검증용 transaction과 ROLLBACK, 대표 데이터, 충분한 권한 통제를 사용합니다.
아래 루프는 endpoint·SQL 측정부터 ORM 로그와 실행 계획, 최소 변경, canary 전후 비교, keep·rollback·iterate 결정까지를 하나의 측정 기록에 누적합니다.
측정 근거가 누적되는 쿼리 최적화 루프
기준 측정·SQL 식별·실행 계획·최소 변경·canary 비교·결정 기록의 근거가 쌓인다.
Nest · Query optimization loop
측정에서 시작하는 쿼리 최적화
같은 부하와 데이터 조건에서 최소 변경의 전후를 비교합니다. SQL·실행 계획·측정값·채택 또는 rollback 결정을 기록해 다음 측정의 기준선으로 씁니다.
- 기준 측정
endpoint p95, query count, 읽은 row와 DB 자원을 같은 구간에서 남깁니다.
- SQL 식별
ORM log로 반복 SQL과 SQL fingerprint를 측정 기록에 연결합니다.
- 계획 확인
EXPLAIN의 scan과 추정 row를 읽고, ANALYZE로 실제 row를 확인할 때는 안전한 환경에서만 실행 측정을 더합니다. - 최소 변경
index나 query shape 가운데 관측한 병목을 겨냥한 한 가지 변경을 준비합니다.
- Canary 전후 비교
같은 부하와 데이터 조건에서 before/after 지표를 비교합니다.
- Keep · rollback · iterate
결정과 승인 기준선을 기록합니다. 다음 회귀나 후보 SQL은 이 기준선에서 1번 측정을 다시 시작합니다.
실선 고리는 작업 순서, 안쪽 점선은 각 단계가 중앙 기록에 쓰는 근거입니다. 마지막 결정이 다음 기준선을 만들기 때문에 이 경로는 끝나는 체크리스트가 아니라 실제 운영 루프입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 기준 측정부터 SQL 식별, 실행 계획 확인, 최소 변경, canary 비교, 결정 기록까지 시계 방향으로 반복한다. 각 단계의 증거는 중앙 기록에 쌓이고 마지막 결정의 기준선이 다음 측정을 시작하게 한다.
- 여섯 단계는 기준 측정→SQL 식별→계획 확인→최소 변경→같은 조건의 canary 비교→keep/rollback 결정의 작업 순서다. 마지막 결정은 왼쪽 되먹임으로 다음 기준 측정을 시작한다. 각 단계의 오른쪽 점선은 SQL/plan/metrics/decision을 공유 기록에 누적하는 근거 관계다. ANALYZE는 실제 실행하므로 안전한 검증 환경 조건을 유지한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 측정 근거가 누적되는 쿼리 최적화 루프 |
| 28 | 여섯 단계는 기준 측정→SQL 식별→계획 확인→최소 변경→같은 조건의 canary 비교→keep/rollback 결정의 작업 순서다. 마지막 결정은 왼쪽 되먹임으로 다음 기준 측정을 시작한다. 각 단계의 오른쪽 점선은 SQL/plan/metrics/decision을 공유 기록에 누적하는 근거 관계다. ANALYZE는 실제 실행하므로 안전한 검증 환경 조건을 유지한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 기준과 SQL 근거 기준 측정·SQL 식별·실행 계획을 기록합니다. | 근거: baseline·SQL·plan · 기록: 중앙 | 바꿀 대상을 증거로 좁힙니다. |
| 최소 변경·canary 최소 변경을 canary에서 같은 기준과 비교합니다. | 변경: 최소 · 검증: canary 비교 | 변경 효과의 근거를 얻습니다. |
| 결정과 다음 기준 결정을 기록하고 새 기준을 다음 측정에 사용합니다. | 산출물: 결정·기준선 · 다음: 측정 | 관측 결과가 후속 판단의 입력이 됩니다. |
측정 근거가 누적되는 쿼리 최적화 루프
기준 측정·SQL 식별·실행 계획·최소 변경·canary 비교·결정 기록의 근거가 쌓인다.
Nest · Query optimization loop
측정에서 시작하는 쿼리 최적화
같은 부하와 데이터 조건에서 최소 변경의 전후를 비교합니다. SQL·실행 계획·측정값·채택 또는 rollback 결정을 기록해 다음 측정의 기준선으로 씁니다.
- 기준 측정
endpoint p95, query count, 읽은 row와 DB 자원을 같은 구간에서 남깁니다.
- SQL 식별
ORM log로 반복 SQL과 SQL fingerprint를 측정 기록에 연결합니다.
- 계획 확인
EXPLAIN의 scan과 추정 row를 읽고, ANALYZE로 실제 row를 확인할 때는 안전한 환경에서만 실행 측정을 더합니다. - 최소 변경
index나 query shape 가운데 관측한 병목을 겨냥한 한 가지 변경을 준비합니다.
- Canary 전후 비교
같은 부하와 데이터 조건에서 before/after 지표를 비교합니다.
- Keep · rollback · iterate
결정과 승인 기준선을 기록합니다. 다음 회귀나 후보 SQL은 이 기준선에서 1번 측정을 다시 시작합니다.
실선 고리는 작업 순서, 안쪽 점선은 각 단계가 중앙 기록에 쓰는 근거입니다. 마지막 결정이 다음 기준선을 만들기 때문에 이 경로는 끝나는 체크리스트가 아니라 실제 운영 루프입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 기준 측정부터 SQL 식별, 실행 계획 확인, 최소 변경, canary 비교, 결정 기록까지 시계 방향으로 반복한다. 각 단계의 증거는 중앙 기록에 쌓이고 마지막 결정의 기준선이 다음 측정을 시작하게 한다.
- 여섯 단계는 기준 측정→SQL 식별→계획 확인→최소 변경→같은 조건의 canary 비교→keep/rollback 결정의 작업 순서다. 마지막 결정은 왼쪽 되먹임으로 다음 기준 측정을 시작한다. 각 단계의 오른쪽 점선은 SQL/plan/metrics/decision을 공유 기록에 누적하는 근거 관계다. ANALYZE는 실제 실행하므로 안전한 검증 환경 조건을 유지한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 측정 근거가 누적되는 쿼리 최적화 루프 |
| 28 | 여섯 단계는 기준 측정→SQL 식별→계획 확인→최소 변경→같은 조건의 canary 비교→keep/rollback 결정의 작업 순서다. 마지막 결정은 왼쪽 되먹임으로 다음 기준 측정을 시작한다. 각 단계의 오른쪽 점선은 SQL/plan/metrics/decision을 공유 기록에 누적하는 근거 관계다. ANALYZE는 실제 실행하므로 안전한 검증 환경 조건을 유지한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 기준과 SQL 근거 기준 측정·SQL 식별·실행 계획을 기록합니다. | 근거: baseline·SQL·plan · 기록: 중앙 | 바꿀 대상을 증거로 좁힙니다. |
| 최소 변경·canary 최소 변경을 canary에서 같은 기준과 비교합니다. | 변경: 최소 · 검증: canary 비교 | 변경 효과의 근거를 얻습니다. |
| 결정과 다음 기준 결정을 기록하고 새 기준을 다음 측정에 사용합니다. | 산출물: 결정·기준선 · 다음: 측정 | 관측 결과가 후속 판단의 입력이 됩니다. |
기준선 기록: endpoint p95, 요청당 query count, rows read, DB 자원을 같은 구간에서 기록합니다.
SQL 식별: ORM log와 trace를 사용해 반복 SQL과 SQL fingerprint를 대상 endpoint에 연결합니다.
계획 확인: scan 방식, 추정 row와 실제 row의 차이, sort·join·buffer 비용을 읽습니다.
최소 변경: index, join·batch, projection, pagination 가운데 관측한 병목을 겨냥한 한 가지를 바꿉니다.
Canary 비교: 같은 부하와 데이터 조건에서 before/after를 비교합니다.
결정 기록: 좋아졌으면 keep, 나빠졌거나 부작용이 크면 rollback하고 다음 가설을 반복합니다.
마지막 결정은 승인된 기준선을 갱신합니다. 다음 회귀나 다음 후보 SQL은 그 기록을 시작점으로 다시 측정하므로, 쿼리 최적화는 끝나는 체크리스트가 아니라 근거가 누적되는 운영 루프입니다.
이것으로 9장 성능 최적화와 스케일링의 두 번째 절을 마칩니다. 다음 절에서는 로드 밸런싱과 애플리케이션 확장을 다룹니다.