데이터베이스 쿼리 최적화
요청 모양과 실제 SQL을 연결해 N+1·인덱스·projection·pagination 후보를 고르고 실행 계획과 canary 지표로 검증합니다.
앞 글에서는 캐시로 원본 조회를 줄였습니다. 이 글에서는 원본인 데이터베이스 쪽에서 느린 쿼리를 찾고 개선합니다.
NestJS 애플리케이션의 응답 지연은 controller보다 데이터베이스 왕복 횟수, 읽은 행 수, 정렬과 조인 비용에서 시작되는 경우가 많습니다. 쿼리 최적화는 느린 엔드포인트와 SQL을 먼저 연결하고, 가장 작은 변경을 적용한 뒤, 같은 조건에서 측정한 전후 지표로 채택 여부를 정하는 작업입니다. 이 글의 예제는 3장에서 쓴 TypeORM 1.x를 쓰되, 3장 첫 글에서 안내한 대로 type을 'postgres'로 바꿔 연결한 PostgreSQL을 기준으로 합니다.
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를 이미 결합했다고 확대하지 않습니다. |
위 그림은 같은 목록 요청이 TypeORM을 거쳐 PostgreSQL에서 1 + N개의 SQL이 되는 경로와, join이나 batch로 왕복 수를 제한하는 경로를 비교합니다. 사용자마다 관계를 따로 조회하면 목록 크기만큼 SQL이 늘어나지만, join은 한 번의 왕복으로 부모와 자식을 함께 가져오고, batch는 부모 목록 한 번과 IN 조회 한 번으로 왕복을 두 번에 묶습니다.
요청 모양과 SQL을 먼저 연결하기
다음 항목을 같은 측정 구간에 함께 남깁니다.
- 엔드포인트와 주요 query parameter
- p50, p95, p99 응답 시간
- 요청당 SQL 개수와 SQL fingerprint
- 반환한 행 수와 읽은 행 수의 차이
- DB의 CPU, I/O, lock 대기, 연결 대기
ORM 로그만 보면 어떤 사용자 요청이 그 SQL을 만들었는지 놓치기 쉽고, 엔드포인트 지표만 보면 어느 SQL이 느린지 알기 어렵습니다. 요청 ID나 trace ID로 두 기록을 연결하면 최적화 대상을 구체적으로 고를 수 있습니다.
N+1: 데이터 개수만큼 늘어나는 왕복
목록 SQL을 한 번 실행한 뒤 사용자마다 관계 SQL을 실행하면 요청당 쿼리 수는 1 + N이 됩니다. 아래 코드는 서비스 클래스의 메서드 발췌입니다.
// 설명을 위한 N+1 예시
async 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 },
}),
})),
);
}다른 eager 조회나 후속 쿼리가 없고 repository 호출 하나가 SQL 하나를 실행한다고 가정하면, 목록이 20명을 반환할 때 첫 SQL 1개와 사용자별 주문 SQL 20개를 더해 모두 21개가 실행됩니다. Promise.all()은 주문 조회를 동시에 시작하게 할 뿐, 20개의 요청을 SQL 하나로 합치지는 않습니다.
해결 목표는 무조건 SQL을 한 번만 실행하는 것이 아니라, 요청 크기가 늘어도 왕복 횟수가 일정하게 제한되도록 만드는 것입니다.
관계가 항상 필요한 화면: 명시적 join
async getUsersWithOrders() {
return this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.orderBy('user.id', 'ASC')
.take(20)
.getMany();
}join은 왕복을 줄이지만, 일대다 관계가 크면 같은 부모 행이 자식 수만큼 반복되어 결과 행이 불어나고 메모리 사용도 커질 수 있습니다. 실제 관계의 개수 분포와 실행 계획을 보고 선택합니다.
관계가 선택적이거나 큰 화면: 묶음 조회
import { In } from 'typeorm';
async 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의 추가 왕복 가운데 어느 쪽이 나은지는 데이터 분포와 화면에 필요한 응답 모양으로 판단합니다.
TypeORM 관계 로딩의 의미
relations: { orders: true }는find*계열 조회에서 필요한 관계를 명시합니다. TypeORM 1.0부터relations와select에 문자열 배열을 쓰는 문법은 제거되었으므로 객체 문법으로 적습니다.eager: true관계는find*계열에서 자동으로 함께 로드됩니다.QueryBuilder에서는 eager 관계에 기대지 말고leftJoinAndSelect처럼 필요한 join을 명시합니다.- TypeORM 1.0부터
nullable: false인ManyToOne과 소유 쪽OneToOne관계는relations나 eager로 로드할 때LEFT JOIN대신INNER 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에 자주 쓰인다는 이유만으로 추가하지 않습니다. 인덱스가 조회를 빠르게 하는 원리는 B+Tree 인덱스에서 볼 수 있으며, 여기서는 조건식, 정렬 방향, 데이터 분포, 읽기와 쓰기의 비율을 함께 보고 실행 계획으로 확인합니다.
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('users')
@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 })로 중복 선언하지 않습니다.
복합 인덱스의 컬럼 순서에는 절대적인 규칙이 없습니다. 첫 번째 컬럼만 쓰는 조회가 있는지, 등호 조건과 범위 조건이 어떻게 섞이는지, 정렬, 선택도, 그리고 DB planner가 실제로 만든 계획을 함께 봅니다. 열 순서가 B+Tree의 정렬 순서를 어떻게 정하는지는 복합 인덱스 설계에서 자세히 다룹니다.
더 깊이: 외래 키 컬럼의 인덱스가 DB마다 다른 이유
기본 키와 고유 제약은 PostgreSQL과 MySQL 모두 인덱스를 자동으로 만듭니다. 하지만 외래 키를 가진 쪽, 즉 이 글의 orders.userId 같은 참조 컬럼은 두 DB가 다르게 다룹니다. PostgreSQL 문서는 외래 키 제약을 선언해도 참조하는 컬럼에 인덱스가 자동으로 생기지 않는다고 밝힙니다. 그런 인덱스가 늘 필요한 것은 아니고 인덱스를 만드는 방법도 여러 가지라서, 만들지는 개발자가 정하게 한 것입니다. 반면 MySQL은 외래 키 검사가 테이블 전체를 읽지 않도록 참조하는 쪽에 외래 키 컬럼을 앞에 둔 인덱스를 요구하고, 없으면 자동으로 만듭니다.
그래서 MySQL에서 빨랐던 WHERE "userId" IN (...) 묶음 조회나 사용자와 주문의 join이, PostgreSQL로 옮기면 orders 전체를 읽는 계획으로 바뀔 수 있습니다. PostgreSQL 문서는 참조되는 쪽의 행을 지우거나 키를 바꿀 때도 참조하는 테이블을 훑어야 하므로, 참조 컬럼에도 인덱스를 두는 편이 좋은 경우가 많다고 설명합니다. PostgreSQL에서는 관계 조회와 삭제가 잦은 외래 키 컬럼마다 인덱스가 실제로 있는지 실행 계획과 스키마에서 확인합니다.
Projection: 필요한 필드만 고르기
필요한 필드만 반환하면 행의 폭, 네트워크 전송량, 직렬화 비용을 줄일 수 있습니다. TypeORM의 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: 크기 제한과 고유한 정렬 키
offset pagination은 skip과 take로 표현합니다. 정렬 값이 같은 행들의 순서가 흔들리지 않도록 id처럼 고유한 값을 정렬 조건의 마지막에 둡니다.
async 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를 거절하지 않습니다. controller 입구의 DTO에서 정수 여부, page >= 1, 팀이 정한 최대 pageSize를 확인해야 합니다.
findAndCount는 한 페이지의 행과 전체 개수를 함께 돌려주는 편의 API입니다. 두 결과가 항상 같은 시점의 데이터라고 가정하지는 않습니다. 정확히 같은 시점이 업무상 필요하다면, 쓰는 데이터베이스와 격리 수준에 맞는 트랜잭션을 따로 설계합니다. 깊은 offset의 비용이나 목록이 바뀌는 동안 생기는 중복·누락이 중요하다면, 고유한 정렬 키를 기준으로 하는 cursor pagination을 검토합니다.
트랜잭션은 필요한 DB 작업만 짧게
트랜잭션은 일관성을 제공하지만, 외부 API 호출이나 긴 계산, 사용자 대기를 그 안에 넣으면 lock을 쥐고 있는 시간이 길어집니다. 격리 수준과 lock 동작은 데이터베이스와 드라이버 설정에 따라 다르므로, 기본값을 추측하지 말고 실제 구성을 확인합니다.
실행 계획과 전후 지표로 확인하기
TypeORM의 logging은 개발·검증 환경에서 SQL과 반복 패턴을 찾는 데 유용합니다. logging에 배열을 주면 고른 종류만 기록되는데, 'query'는 실행한 모든 SQL을, 'error'는 실패한 SQL을 출력합니다. 요청 하나에 같은 모양의 SELECT가 목록 크기만큼 찍힌다면 앞에서 본 N+1을 의심할 수 있습니다. 모든 SQL과 매개변수가 기록되므로, 운영 환경에서 그대로 켜 두면 로그 양이 크게 늘고 값이 노출될 수 있습니다.
TypeOrmModule.forRoot({
// ...
logging: ['query', 'error'],
});로그에서 후보 SQL을 찾았다면 다음은 그 SQL의 실행 계획을 봅니다. PostgreSQL의 EXPLAIN은 planner가 추정한 실행 계획을 보여 줍니다. 이 형태는 문장을 실행하지 않으며, 출력의 cost와 rows는 측정한 값이 아니라 통계에 기반한 추정치입니다.
EXPLAIN
SELECT id, email
FROM users
WHERE email = '[email protected]';email에 쓸 수 있는 인덱스가 있으면 계획에 Index Scan 같은 노드가, 없으면 테이블 전체를 읽는 Seq Scan이 나올 수 있습니다. 다만 행이 아주 적은 테이블에서는 인덱스가 있어도 planner가 Seq Scan을 고를 수 있으므로, 노드 이름만으로 문제를 판단하지 않습니다. 실제로 읽은 행과 buffer를 확인하려면 안전한 검증 환경에서 다음처럼 실행할 수 있습니다. ANALYZE를 붙이면 추정치 옆에 실제 실행 시간과 행 수가, BUFFERS를 붙이면 읽은 버퍼 블록 수가 함께 나옵니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email
FROM users
WHERE email = '[email protected]';ANALYZE 옵션은 문장을 실제로 실행하고, 측정에 따른 부하도 더합니다. INSERT, UPDATE, DELETE 같은 변경 문장은 부수 효과가 생기므로 운영 환경에서 무심코 실행하지 않습니다. 필요하다면 검증용 트랜잭션과 ROLLBACK, 대표성 있는 데이터, 충분한 권한 통제를 함께 씁니다.
측정 근거가 누적되는 쿼리 최적화 루프
기준 측정·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 비교 | 변경 효과의 근거를 얻습니다. |
| 결정과 다음 기준 결정을 기록하고 새 기준을 다음 측정에 사용합니다. | 산출물: 결정·기준선 · 다음: 측정 | 관측 결과가 후속 판단의 입력이 됩니다. |
위 그림은 엔드포인트와 SQL 측정에서 시작해 ORM 로그와 실행 계획을 읽고, 가장 작은 변경을 적용한 뒤 canary로 전후를 비교하고, 유지·되돌리기·다시 시도 가운데 하나를 결정하는 반복 과정입니다. 각 단계의 결과는 하나의 측정 기록에 쌓이며, 다음 번 최적화는 이 기록을 출발점으로 삼습니다. 단계별로 할 일은 다음과 같습니다.
- 기준 기록: 엔드포인트 p95, 요청당 쿼리 수, 읽은 행 수, DB 자원을 같은 구간에서 기록합니다.
- SQL 식별: ORM 로그와 trace로 반복되는 SQL과 SQL fingerprint를 대상 엔드포인트에 연결합니다.
- 계획 확인: scan 방식, 추정 행 수와 실제 행 수의 차이, 정렬·조인·buffer 비용을 읽습니다.
- 최소 변경: 인덱스, join·batch, projection, pagination 가운데 측정한 병목을 겨냥한 한 가지만 바꿉니다.
- canary 비교: 같은 부하와 데이터 조건에서 변경 전후를 비교합니다.
- 결정 기록: 좋아졌으면 유지하고, 나빠졌거나 부작용이 크면 되돌린 뒤 다음 가설로 다시 시도합니다.
마지막 결정은 기준 기록을 갱신합니다. 다음 회귀나 다음 후보 SQL은 그 기록에서 다시 측정을 시작하므로, 쿼리 최적화는 한 번 끝내는 점검표가 아니라 측정 기록이 쌓여 가는 운영 과정입니다.
정리
- 쿼리 최적화는 엔드포인트 지표와 SQL을 요청 ID나 trace ID로 연결하는 데서 시작합니다.
- N+1은 목록 크기만큼 왕복이 늘어나는 문제이며, join이나
In()을 쓴 묶음 조회로 요청당 왕복 수를 제한합니다. - TypeORM 1.0에서는
relations와select를 객체 문법으로 쓰고,nullable: false인ManyToOne관계는INNER JOIN으로 로드됩니다. - 인덱스와 복합 인덱스의 컬럼 순서는 조건식, 정렬, 데이터 분포를 보고 정하고,
EXPLAIN으로 실제 계획을 확인합니다. - pagination에는 고유한 정렬 키를 마지막에 두고, 변경은 한 번에 하나씩 적용해 같은 조건의 전후 지표로 판단합니다.
연습 문제
1. SQL은 몇 개 실행될까
목록이 5명을 반환했고, repository 호출 하나가 SQL 하나를 실행한다고 가정합니다. N+1 코드와 묶음 조회 코드의 SQL 수는 각각 몇 개일까요? 목록이 비어 있으면 묶음 조회 코드가 주문 SQL도 실행할까요? 위의 join 코드도 같은 숫자를 항상 보장한다고 말할 수 있을까요?
해설 보기
N+1 코드는 1 + 5 = 6개입니다. 묶음 조회는 사용자 목록 1개와 IN 주문 조회 1개로 2개입니다. 목록이 비어 있으면 users.length === 0에서 바로 반환하므로 목록 SQL 1개만 실행합니다.
join 코드는 take()와 일대다 join을 함께 쓰면 TypeORM이 페이지에 들어갈 부모 ID를 먼저 조회하는 등 추가 SQL을 만들 수 있고, 관계 설정과 드라이버에 따라서도 달라지므로 실제 로그로 확인해야 합니다. 이 계산은 주어진 가정 아래의 비교일 뿐, 모든 ORM 호출에 통하는 SQL 수는 아닙니다.
2. 업그레이드 뒤 줄어든 목록
TypeORM 0.3에서 1.x로 올린 뒤, Order 엔티티에 @ManyToOne(() => User, { nullable: false }) user가 있는 상태에서 orderRepository.find({ relations: { user: true } })의 결과가 이전보다 적어졌습니다. orderRepository.count()는 그대로입니다. 무엇을 의심해야 할까요?
해설 보기
TypeORM 1.0부터 nullable: false인 ManyToOne 관계는 relations로 로드할 때 INNER JOIN을 씁니다. 데이터베이스에 연결된 사용자가 없는 주문, 즉 외래 키가 비어 있거나 존재하지 않는 사용자를 가리키는 잘못된 행이 있다면, LEFT JOIN을 쓰던 이전과 달리 그 주문들이 결과에서 빠집니다.
count()는 join 없이 주문 행만 세므로 숫자가 그대로입니다. 먼저 고아 행이 있는지 데이터를 점검해 정리하고, 정말로 사용자가 없는 주문이 허용되는 업무라면 관계를 nullable: true로 바꿉니다.
3. 선택적인 필터 값
목록 API에서 status 쿼리 매개변수를 선택적으로 받아 orderRepository.find({ where: { status: query.status } })로 조회합니다. status 없이 요청하면 TypeORM 1.x에서 어떤 일이 생길까요? 어떻게 고쳐야 할까요?
해설 보기
status를 보내지 않으면 query.status는 undefined입니다. TypeORM 0.3에서는 where의 undefined 속성을 조용히 무시해 전체 목록이 나왔지만, 1.0부터는 where에 null이나 undefined가 들어가면 기본적으로 오류를 던집니다.
필터가 없을 때는 속성 자체를 넣지 않도록 where: query.status ? { status: query.status } : {}처럼 조건을 만들고, status가 실제로 NULL인 행을 찾으려는 경우에만 IsNull()을 씁니다. 이렇게 하면 “필터 없음”과 “값이 비어 있는 행 찾기”를 코드에서 분명히 구분할 수 있습니다.
다음 글 로드 밸런싱과 수평적 확장에서는 하나의 서버를 빠르게 만드는 데서 나아가, 여러 인스턴스로 부하를 나누는 방법을 다룹니다.