본문으로 건너뛰기

안동민 개발노트

본문 시작

데이터베이스 쿼리 최적화

요청 모양과 실제 SQL을 연결해 N+1·인덱스·projection·pagination 후보를 고르고 실행 계획과 canary 지표로 검증합니다.

NestJS 애플리케이션의 응답 지연은 컨트롤러보다 데이터베이스 왕복, 읽은 row 수, 정렬·조인 비용에서 시작되는 경우가 많습니다. 최적화는 먼저 느린 endpoint와 SQL을 연결하고, 가장 작은 변경을 적용한 뒤 같은 조건의 전후 지표로 채택 여부를 결정하는 작업입니다.

아래 다이어그램은 같은 목록 요청이 TypeORM을 거쳐 PostgreSQL에서 1 + N SQL이 되는 경로와, join 또는 batch로 왕복 수를 제한하는 경로를 비교합니다.

같은 사용자 목록 요청에서 항목마다 관계 SQL을 반복하는 1 더하기 N 경로와 join 또는 IN batch로 왕복 수를 제한하는 경로를 비교하고, 증상에 따라 인덱스·projection·pagination을 선택하는 기준

NestJS · TypeORM · paired sequence

요청 모양에서 SQL 왕복과 실행 계획까지 함께 본다

같은 목록 요청도 관계를 항목마다 읽으면 1 + N번, join이나 batch로 묶으면 제한된 횟수의 SQL이 됩니다. join이 항상 정답은 아니므로 행 증폭과 실제 실행 계획을 확인한 뒤 조회 모양을 고릅니다.

N+1과 bounded 관계 조회 비교 시퀀스 HTTP 목록 요청을 TypeORM이 SQL로 바꾸고 PostgreSQL이 실행한다. 관계를 사용자마다 조회하면 N개의 추가 SQL이 반복되고, 필요한 관계를 join 또는 IN batch로 묶으면 왕복 수가 제한된다. ALT [relation을 항목마다 접근] [목록 화면에 relation이 필요] GET /users · include orders · take 20 SELECT users · projection · stable order plan + 20 users N × SELECT orders WHERE user_id = ? rows × N · 1 + N JOIN 또는 WHERE user_id IN (...) bounded round trips page DTO HTTP 요청 TypeORM PostgreSQL planner · executor 관계 접근 횟수가 데이터 개수와 함께 늘어난다 join의 행 증폭과 batch의 추가 왕복을 실제 데이터로 비교한다
증상에서 시작해 가장 작은 조회 변경을 고르는 기준
관측한 증상먼저 검토할 선택반드시 확인할 근거
같은 relation SQL 반복명시적 join 또는 IN batch왕복 수와 join 행 증폭
filter·sort의 읽기 비용 증가실제 predicate와 정렬을 반영한 index 후보실행 계획, 데이터 분포, 쓰기 비용
넓은 row와 큰 응답 payloadselect projection필드 계약과 전송량
무제한 목록 또는 불안정한 페이지take와 고유 tie-breaker 정렬페이지 중복·누락과 deep offset 비용
요청 GET /users · relation 포함 · page limit
1 + N

항목마다 relation SQL 반복

목록 SQL 한 번 뒤 사용자마다 주문 SQL을 실행하면 데이터 개수에 따라 DB 왕복이 늘어납니다.

bounded

join 또는 IN batch

관계가 필요한 범위를 묶되 join 행 증폭과 batch 추가 왕복을 실제 데이터로 비교합니다.

index

filter·sort 비용

predicate, 정렬, 데이터 분포에 맞는 후보를 실행 계획과 쓰기 비용으로 검증합니다.

select

넓은 row·payload

필요한 필드만 projection하고 응답 계약과 전송량을 확인합니다.

page

무제한·불안정 목록

limit과 고유 tie-breaker 정렬을 두고 중복·누락과 deep offset 비용을 확인합니다.

반복 SQL은 N+1의 직접 증거입니다. index, projection, pagination은 서로 대체재가 아니라 각각 scan 비용, row 폭, 결과 집합 크기를 줄이는 선택이며 전후 측정으로 채택합니다.


요청 모양과 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이 됩니다.

src/user/users.service.ts
// 설명을 위한 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 한 번이 아니라 요청 크기가 늘어도 왕복 횟수가 제한되도록 만드는 것입니다.

관계가 항상 필요한 화면: 명시적 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의 추가 왕복 중 어느 쪽이 나은지는 데이터 분포와 화면 계약으로 판단합니다.

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, 정렬 방향, 데이터 분포, 읽기·쓰기 비율을 함께 보고 실행 계획으로 검증합니다.

src/user/user.entity.ts
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은 skiptake로 표현할 수 있습니다. 결과가 같은 정렬 값에서 흔들리지 않도록 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 };
}

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과 반복 패턴을 찾는 데 유용합니다.

src/app.module.ts
TypeOrmModule.forRoot({
  // ...
  logging: ['query', 'error'],
});

PostgreSQL의 EXPLAIN은 planner의 추정 계획을 보여 줍니다.

EXPLAIN
SELECT id, email
FROM users
WHERE email = 'test@example.com';

실제 row와 buffer를 확인하려면 안전한 검증 환경에서 다음처럼 실행할 수 있습니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email
FROM users
WHERE email = 'test@example.com';

ANALYZE 옵션은 문장을 실제로 실행하고 측정 overhead도 추가합니다. INSERT, UPDATE, DELETE 같은 변경 문장은 부작용이 발생하므로 운영에서 무심코 실행하지 않습니다. 필요한 경우 검증용 transaction과 ROLLBACK, 대표 데이터, 충분한 권한 통제를 사용합니다.

아래 루프는 endpoint·SQL 측정부터 ORM 로그와 실행 계획, 최소 변경, canary 전후 비교, keep·rollback·iterate 결정까지를 하나의 측정 기록에 누적합니다.

endpoint와 SQL 기준 측정, ORM 로그, 실행 계획, 최소 변경, canary 전후 비교, keep 또는 rollback 결정을 반복하며 SQL fingerprint와 기준선과 결정을 중앙 측정 기록에 누적하는 최적화 루프

NestJS · query optimization loop

결정과 기준선을 남겨 다음 측정의 출발점으로 쓴다

최소 변경을 canary에서 같은 조건으로 비교하고, 채택·rollback 근거를 하나의 측정 기록에 누적합니다. 마지막 결정이 승인된 기준선을 갱신하므로 다음 회귀나 다음 후보 SQL은 그 기록에서 다시 측정을 시작합니다.

측정 기록을 공유하는 여섯 단계 쿼리 최적화 루프 기준 측정부터 SQL 식별, 실행 계획 확인, 최소 변경, canary 비교, 결정 기록까지 시계 방향으로 반복한다. 각 단계의 증거는 중앙 기록에 쌓이고 마지막 결정의 기준선이 다음 측정을 시작하게 한다. 1. 기준 측정 endpoint p95 · query count 2. SQL 식별 ORM log · fingerprint 3. 계획 확인 EXPLAIN · rows · scan 4. 최소 변경 index · query shape 5. Canary 비교 before · after · same load 6. 결정 기록 keep · rollback · iterate 측정 근거 SQL · plan · metrics accepted baseline · decision 6의 결정과 기준선이 1의 다음 회귀 측정을 시작한다
공유 기록 SQL fingerprint · plan · before/after · decision
  1. 기준 측정

    endpoint p95, query count, 읽은 row와 DB 자원을 같은 구간에서 남깁니다.

  2. SQL 식별

    ORM log로 반복 SQL과 SQL fingerprint를 측정 기록에 연결합니다.

  3. 계획 확인

    EXPLAIN의 scan, 추정 row, 실제 row를 읽고 안전한 환경에서만 실행 측정을 더합니다.

  4. 최소 변경

    index나 query shape 가운데 관측한 병목을 겨냥한 한 가지 변경을 준비합니다.

  5. Canary 전후 비교

    같은 부하와 데이터 조건에서 before/after 지표를 비교합니다.

  6. Keep · rollback · iterate

    결정과 승인 기준선을 기록합니다. 다음 회귀나 후보 SQL은 이 기준선에서 1번 측정을 다시 시작합니다.

실선 고리는 작업 순서, 안쪽 점선은 각 단계가 중앙 기록에 쓰는 근거입니다. 마지막 결정이 다음 기준선을 만들기 때문에 이 경로는 끝나는 체크리스트가 아니라 실제 운영 루프입니다.

기준선 기록: 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장 성능 최적화와 스케일링의 두 번째 절을 마칩니다. 다음 절에서는 비동기 처리와 논블로킹 I/O를 다룹니다.