본문으로 건너뛰기

안동민 개발노트

본문 시작

데이터베이스 쿼리 최적화

인덱스·N+1·선택 조회·페이지네이션·로딩 전략을 점검하고 실행 계획으로 데이터베이스 병목을 찾습니다.

이 절에서는 NestJS 환경에서 데이터베이스 쿼리 최적화를 다룹니다.

대부분의 웹 애플리케이션은 데이터를 저장하고 조회하기 위해 데이터베이스에 크게 의존합니다.

따라서 비효율적인 데이터베이스 쿼리는 전체 시스템 성능을 떨어뜨리는 치명적인 병목이 될 수 있습니다.

쿼리 최적화는 코드 몇 줄 수정으로 끝나는 작업이 아니라, 데이터베이스 동작 방식, 인덱스 활용, ORM(Object-Relational Mapping) 특성을 함께 고려해야 하는 중요한 작업입니다.

먼저 다음 다이어그램은 느린 쿼리를 고칠 때 무엇을 함께 확인해야 하는지 큰 흐름으로 정리합니다.

느린 쿼리는 API, ORM, DB를 같이 봐야 고친다

SQL 한 줄만 보지 말고 요청 화면, 관계 로딩, 인덱스, 반환 데이터 크기를 같은 흐름에서 점검합니다.

  1. 1
    응답 지연

    API latency가 DB 실행 시간에 끌려갑니다.

  2. 2
    리소스 상승

    CPU, I/O, connection 대기가 늘어납니다.

  3. 3
    비용 증가

    불필요한 scan과 전송량이 누적됩니다.

  4. 4
    동시성 저하

    긴 transaction과 lock이 요청을 막습니다.

  5. 5
    Request shape

    화면이 실제로 필요한 데이터부터 좁힙니다. 필터와 정렬 WHERE, JOIN, ORDER BY가 인덱스 후보입니다. 반환 범위 보여줄 컬럼과 관계만 고릅니다.

  6. 6
    NestJS / ORM

    편한 API 뒤에 숨어 있는 SQL 모양을 확인합니다. Repository find 관계 로딩과 SELECT 범위가 숨을 수 있습니다. QueryBuilder join, select, where를 명시합니다.

  7. 7
    Database engine

    DB가 실제로 어떻게 읽고 기다리는지 봅니다. EXPLAIN scan 방식, 인덱스, rows 예측을 확인합니다. Lock 긴 작업은 다른 요청의 대기로 보입니다.

증상먼저 볼 것대표 조치
rows가 많다반환 row와 읽은 row를 분리pagination, select 축소
쿼리 수가 많다같은 SQL 반복 여부join 또는 batch loading
scan이 크다필터와 정렬 컬럼단일/복합 인덱스 검토
락이 길다transaction 범위작업 범위와 격리 수준 축소

데이터베이스 쿼리 최적화의 중요성

데이터베이스 쿼리 최적화는 다음과 같은 이유로 매우 중요합니다.

  • 응답 시간 단축: 쿼리 실행 시간이 줄어들면 애플리케이션의 전체 응답 시간이 직접적으로 개선됩니다.
  • 서버 리소스 절약: 불필요한 데이터베이스 연산을 줄여 CPU, 메모리, I/O 등 서버 리소스를 절약하고, 더 많은 동시 요청을 처리할 수 있게 합니다.
  • 데이터베이스 부하 감소: 데이터베이스 서버의 부하가 줄어들어 안정성이 높아지고, 확장 필요성이 완화될 수 있습니다.
  • 사용자 경험 향상: 웹 페이지 로딩 속도나 기능의 반응 속도가 빨라져 사용자 만족도가 높아집니다.
  • 운영 비용 절감: 클라우드 환경에서 데이터베이스 사용량에 따른 비용을 줄일 수 있습니다.

일반적인 쿼리 최적화 기법

데이터베이스 쿼리 최적화에는 다양한 기법이 있지만, NestJS와 관계형 데이터베이스(TypeORM, Prisma 등)를 사용하는 경우 주로 고려할 사항들은 다음과 같습니다.

인덱스(Index) 활용

가장 기본적인 최적화 기법입니다.

데이터베이스 인덱스는 책의 찾아보기와 유사하여, 특정 컬럼의 값을 빠르게 찾을 수 있도록 돕습니다.

  • 언제 사용해야 하는가?: WHERE, JOIN, ORDER BY 절에서 자주 사용되는 컬럼에 인덱스를 생성합니다.
  • 주의사항
    • 인덱스는 데이터 삽입(INSERT), 업데이트(UPDATE), 삭제(DELETE) 시 오버헤드를 발생시킵니다. 데이터 변경이 빈번한 테이블에는 신중하게 적용해야 합니다.
    • 너무 많은 인덱스는 오히려 성능을 저하시킬 수 있습니다.
    • 복합 인덱스(Compound Index): 여러 컬럼을 조합하여 인덱스를 만들 때, 컬럼 순서가 중요합니다. WHERE a = ? AND b = ? 쿼리에는 (a, b) 인덱스가 (b, a) 인덱스보다 효율적입니다.
TypeORM에서의 인덱스 적용 예시
src/user/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';

@Entity()
@Index(['email'], { unique: true }) // email 컬럼에 UNIQUE 인덱스 추가
@Index(['lastName', 'firstName']) // lastName, firstName 복합 인덱스 추가 (쿼리: WHERE lastName = ? AND firstName = ?)
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column({ unique: true }) // 컬럼 레벨에서 유니크 제약조건과 함께 인덱스 생성
  email: string;

  @Column()
  age: number;
}

N+1 쿼리 문제 해결

가장 흔하게 발생하는 성능 문제입니다.

하나의 메인 쿼리로 N개의 결과를 가져온 후, 각 결과에 대해 추가로 1개의 쿼리(총 N개)를 실행하여 관련 데이터를 가져올 때 발생합니다.

아래 다이어그램은 N+1 문제가 어떤 쿼리 수 증가로 이어지고, 언제 join이나 batch loading으로 바꿔야 하는지 보여줍니다.

N+1은 “목록 1번 + 항목마다 N번”으로 왕복이 폭증하는 패턴이다

사용자 목록을 가져온 뒤 각 사용자의 주문을 반복 조회하면 데이터 수가 늘수록 DB 왕복이 선형으로 늘어납니다.

  1. Lazy 반복 조회

    Before: 1 + N Q1 SELECT users 목록 Q2 SELECT orders WHERE userId = 1 Q3 SELECT orders WHERE userId = 2 Qn 사용자마다 같은 SQL 반복

  2. 관계 로딩 고정

    After: 1 or 2 JOIN users와 orders를 한 SQL 계획 안에서 조회 BATCH WHERE userId IN (...)로 한 번 더 조회 SELECT 필요한 컬럼만 반환해 전송량 축소

  3. 목록 + 관계

    N+1 후보입니다. 로그에서 같은 SQL 반복을 찾습니다.

  4. 항상 필요

    명시적 join 또는 relations를 씁니다.

  5. 조건부 필요

    batch loading이나 별도 endpoint로 나눕니다.

예시: 사용자 목록을 조회하고, 각 사용자의 주문 내역을 개별 쿼리로 가져오는 경우.

// 비효율적인 N+1 쿼리 (의사 코드)
// UsersService
async getUsersWithOrders(): Promise<any[]> {
  const users = await userRepository.find(); // 1번 쿼리
  const usersWithOrders = [];
  for (const user of users) {
    const orders = await orderRepository.find({ where: { userId: user.id } }); // N번 쿼리
    usersWithOrders.push({ ...user, orders });
  }
  return usersWithOrders;
}

해결 방법: JOIN 또는 Eager Loading을 활용하여 한 번의 쿼리로 필요한 모든 데이터를 가져옵니다.

TypeORM에서의 N+1 해결 예시 (Eager Loading 또는 Join)
  • Eager Loading (Entity 관계 정의 시 eager: true)
    • 엔티티 관계에 eager: true를 설정하면, 해당 엔티티를 조회할 때 관계된 엔티티도 자동으로 로드됩니다. 편리하지만, 항상 로드되므로 불필요한 경우에도 JOIN이 발생하여 성능 저하의 원인이 될 수 있습니다. 신중하게 사용해야 합니다.
    // src/user/user.entity.ts (User와 Order의 관계)
    import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
    import { Order } from '../order/order.entity'; // Order 엔티티 임포트
    
    @Entity()
    export class User {
      @PrimaryGeneratedColumn()
      id: number;
    
      @Column()
      name: string;
    
      @OneToMany(() => Order, order => order.user, { eager: true }) // eager: true 추가
      orders: Order[];
    }
  • Lazy Loading (필요할 때만 로드)
    • eager: false (기본값)로 설정하고, 필요할 때 await user.orders처럼 호출하거나, 쿼리 시 relations 옵션을 사용합니다.
  • Join을 사용한 명시적 로딩 (relations 또는 QueryBuilder): 가장 권장되는 방식입니다.

    필요한 경우에만 명시적으로 JOIN을 수행합니다.

    // src/user/users.service.ts
    import { Injectable } from '@nestjs/common';
    import { Repository } from 'typeorm';
    import { InjectRepository } from '@nestjs/typeorm';
    import { User } from './user.entity';
    import { Order } from '../order/order.entity'; // Order 엔티티 임포트
    
    @Injectable()
    export class UsersService {
      constructor(
        @InjectRepository(User)
        private usersRepository: Repository<User>,
        @InjectRepository(Order) // Order Repository도 주입
        private ordersRepository: Repository<Order>,
      ) {}
    
      async getUsersWithOrders(): Promise<User[]> {
        // QueryBuilder를 사용하여 JOIN을 명시적으로 수행
        return this.usersRepository.createQueryBuilder('user')
          .leftJoinAndSelect('user.orders', 'order') // 'user' 엔티티의 'orders' 관계를 JOIN하고 선택
          .getMany();
      }
    
      async getRecentOrdersForUser(userId: number): Promise<Order[]> {
        // 특정 사용자의 최근 주문을 가져올 때도 JOIN을 활용하여 사용자 정보 함께 가져오기
        return this.ordersRepository.createQueryBuilder('order')
          .leftJoinAndSelect('order.user', 'user')
          .where('order.userId = :userId', { userId })
          .orderBy('order.createdAt', 'DESC')
          .limit(10)
          .getMany();
      }
    }

필요한 데이터만 조회 (SELECT * 지양)

SELECT *는 모든 컬럼을 가져오므로 불필요한 네트워크 I/O와 메모리 사용을 증가시킵니다.

필요한 컬럼만 명시적으로 선택합니다.

TypeORM 예시
// 모든 컬럼을 가져오는 대신 특정 컬럼만 선택
const users = await this.usersRepository.find({
  select: ['id', 'name', 'email'], // 필요한 컬럼만 명시
});

// QueryBuilder에서도 select 사용
const user = await this.usersRepository.createQueryBuilder('user')
  .select(['user.id', 'user.name', 'user.email'])
  .where('user.id = :id', { id: 1 })
  .getOne();

페이지네이션(Pagination)

대용량 데이터를 한 번에 가져오는 것을 피하고, 페이지 단위로 데이터를 분할하여 가져옵니다.

OFFSET/LIMIT 또는 cursor-based pagination을 사용합니다.

TypeORM 예시 (LIMIT/OFFSET)
// UsersService
async getUsersPaginated(page: number, limit: number): Promise<[User[], number]> { // [데이터, 전체 개수] 반환
  const skip = (page - 1) * limit;
  return this.usersRepository.findAndCount({ // findAndCount는 데이터와 전체 개수를 동시에 가져옴
    skip: skip,
    take: limit,
    order: { id: 'ASC' }, // 정렬은 필수
  });
}

트랜잭션(Transaction) 최적화

트랜잭션은 데이터 일관성을 보장하지만, 장시간 유지되는 트랜잭션은 데이터베이스 잠금(Lock)을 유발하여 동시성을 저해하고 성능 병목을 일으킬 수 있습니다.

  • 트랜잭션 최소화: 가능한 짧게 유지하고, 필요한 작업만 포함합니다.
  • 격리 수준(Isolation Level) 조정: 필요에 따라 적절한 격리 수준을 선택합니다. (기본값은 보통 READ COMMITTED 또는 REPEATABLE READ)
TypeORM 트랜잭션 예시
// UsersService
async transferFunds(fromUserId: number, toUserId: number, amount: number): Promise<void> {
  await this.usersRepository.manager.transaction(async transactionalEntityManager => {
    const fromUser = await transactionalEntityManager.findOne(User, { where: { id: fromUserId } });
    const toUser = await transactionalEntityManager.findOne(User, { where: { id: toUserId } });

    if (!fromUser || !toUser) {
      throw new Error('User not found');
    }
    // 잔액 확인 및 업데이트 로직 (간단화)
    // await transactionalEntityManager.save(fromUser);
    // await transactionalEntityManager.save(toUser);
  });
}

Lazy Loading과 Eager Loading의 균형

TypeORM과 같은 ORM에서 관계(Relationship) 로딩 방식은 성능에 큰 영향을 미칩니다.

  • Lazy Loading (기본값): 관계된 데이터를 필요할 때만 조회합니다. N+1 쿼리를 유발할 수 있지만, 항상 모든 관계를 로드할 필요가 없을 때 유용합니다.
  • Eager Loading (eager: true): 엔티티 조회 시 관계된 데이터도 항상 함께 조회합니다. 편리하지만, 필요 없는 경우에도 JOIN이 발생하여 성능 저하를 가져올 수 있습니다.
  • 해결책: 기본적으로 Lazy Loading을 유지하고, 필요한 경우에만 relations 옵션이나 QueryBuilderjoinAndSelect를 사용하여 명시적으로 로드하는 것이 가장 유연하고 효율적인 방법입니다.

조회 최적화는 인덱스, 조인, 선택 컬럼, 페이지네이션을 따로 보지 않고 요청 화면과 데이터 접근 패턴을 함께 놓고 결정해야 합니다.

조회 최적화는 화면 요구를 네 개 레버로 번역하는 일이다

인덱스, 조인, 선택 컬럼, 페이지네이션은 요청 패턴을 DB가 처리하기 쉬운 모양으로 바꾸는 기준입니다.

  1. Index

    WHERE, JOIN, ORDER BY 컬럼과 복합 인덱스 순서를 맞춥니다.

  2. Join / relation

    N+1은 join 또는 batch loading으로 왕복 수를 줄입니다.

  3. Projection

    화면에 필요한 컬럼만 DTO 경계로 반환합니다.

  4. Pagination

    큰 목록은 limit/offset 또는 cursor로 잘라 읽습니다.

증상IndexJoinProjectionPagination
scan rows 큼우선보조보조보조
쿼리 수 많음보조우선보조보류
전송량 큼보류보조우선우선

쿼리 성능 분석 도구 활용

쿼리 최적화는 단순히 코드를 수정하는 것을 넘어, 실제 쿼리가 어떻게 실행되는지 분석하는 것이 중요합니다.

다음 다이어그램은 로그, 실행 계획, 지표를 읽고 다시 조정하는 순서를 묶어 보여줍니다.

쿼리 최적화는 로그, 실행 계획, 지표를 반복해서 맞추는 루프다

느린 SQL을 찾고, DB가 어떻게 읽는지 본 뒤, 수정 후 실제 latency와 리소스가 좋아졌는지 다시 확인합니다.

  1. ORM logging

    반복 SQL과 예상보다 큰 SELECT를 찾습니다.

  2. EXPLAIN

    index 사용, rows 추정, scan 방식을 읽습니다.

  3. DB metrics

    CPU, I/O, lock, connection 대기를 봅니다.

  4. 느린 지점 포착

    API latency와 slow query log에서 후보 SQL을 분리합니다.

  5. 실행 계획 읽기

    풀 스캔, 임시 정렬, 과도한 rows를 확인합니다.

  6. 쿼리 모양 수정

    index, join, select, pagination 범위를 조정합니다.

  7. 전후 비교

    p95, query count, rows read, DB 리소스를 비교합니다.

  8. type / access

    index lookup인지 full scan인지 확인합니다.

  9. rows

    읽을 것으로 예상한 행 수가 과도한지 봅니다.

  10. extra

    sort, temp table, filter 조건을 추적합니다.

  • EXPLAIN (또는 EXPLAIN ANALYZE)
    • 대부분의 관계형 데이터베이스(MySQL, PostgreSQL 등)는 EXPLAIN 명령어를 제공하여 쿼리 실행 계획을 보여줍니다.
    • 쿼리가 어떤 인덱스를 사용하는지, 풀 스캔을 하는지, 임시 테이블을 사용하는지 등을 파악하여 비효율적인 부분을 찾아낼 수 있습니다.
    EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
  • 데이터베이스 모니터링 도구
    • 데이터베이스 시스템 자체에서 제공하는 모니터링 도구(예: PostgreSQL pg_stat_statements, MySQL Performance Schema)나 클라우드 제공업체의 모니터링 서비스(AWS CloudWatch, GCP Cloud Monitoring)를 활용하여 느린 쿼리(Slow Query)를 식별하고, CPU/메모리/I/O 사용량 등을 추적합니다.
  • ORM 로깅
    • TypeORM 같은 ORM은 실행되는 SQL 쿼리를 콘솔에 로깅하는 기능을 제공합니다. 개발 환경에서 이를 활성화하여 N+1 쿼리나 비효율적인 쿼리를 시각적으로 파악할 수 있습니다.
    src/main.ts 또는 typeorm config
    TypeOrmModule.forRoot({
      // ...
      logging: true, // SQL 쿼리 로깅 활성화
      // 또는 ['query', 'error', 'schema'] 처럼 특정 로그만 선택
    }),

쿼리 최적화는 코드 수정 이후 운영 지표가 실제로 좋아졌는지 확인해야 완성됩니다.

아래 다이어그램은 변경 전후를 검증하는 루틴입니다.

쿼리 최적화는 배포 후 숫자가 좋아져야 끝난다

인덱스 추가나 join 변경은 코드 리뷰로 끝나지 않습니다. 기준선, 제한 배포, 비교 지표, 되돌림 조건을 함께 둡니다.

  1. 사용자 기준

    p95 응답 시간과 에러율이 내려가야 합니다.

  2. DB 기준

    rows read, CPU, I/O, lock wait가 줄어야 합니다.

  3. 운영 기준

    쓰기 지연이나 인덱스 부작용이 없어야 합니다.

  4. Baseline

    변경 전 slow query와 p95를 저장합니다.

  5. Plan

    EXPLAIN으로 기대 개선을 문서화합니다.

  6. Canary

    일부 트래픽이나 낮은 부하 시간에 먼저 적용합니다.

  7. Compare

    전후 지표를 같은 기간과 조건으로 비교합니다.

  8. Decide

    개선이 없거나 부작용이 크면 되돌립니다.

  9. Keep

    latency와 DB 부하가 같이 좋아졌다.

  10. Roll back

    읽기는 좋아졌지만 쓰기나 lock이 나빠졌다.

  11. Iterate

    효과가 작으면 데이터 접근 패턴을 다시 본다.

다음 다이어그램은 인덱스, N+1, 페이지네이션, projection을 실제 판단 기준으로 압축해 다시 정리합니다.

느린 조회는 네 질문으로 먼저 좁힌다

인덱스, N+1, 페이지네이션, projection은 NestJS ORM 병목을 빠르게 분류하는 1차 판단 기준입니다.

  1. 너무 많이 읽는다

    rows read가 반환 row보다 훨씬 큽니다.

  2. Index

    WHERE·ORDER BY의 scan 범위를 줄입니다.

  3. 너무 자주 왕복한다

    같은 형태의 SQL이 관계마다 반복됩니다.

  4. Join / batch

    N+1 호출을 한 번의 묶음 조회로 바꿉니다.

  5. 한 번에 너무 많다

    목록 요청이 대량 row를 반환합니다.

  6. Pagination

    한 요청의 조회 범위를 제한합니다.

  7. 필요 없는 값이 크다

    화면에 없는 컬럼과 관계까지 전송합니다.

  8. Projection

    SELECT 대상을 필요한 값으로 좁힙니다.

  9. 1. 로그

    그 반복 SQL과 느린 SQL을 찾습니다.

  10. 2. 계획

    계획 EXPLAIN으로 rows와 index를 봅니다.

  11. 3. 변경

    변경 가장 작은 레버부터 바꿉니다.

  12. 4. 검증

    검증 전후 지표가 좋아졌는지 확인합니다.

데이터베이스 쿼리 최적화는 한 번으로 끝나는 작업이 아니라 지속적인 과정입니다.

애플리케이션 성장과 데이터 증가에 따라 새로운 병목이 생길 수 있으므로, 주기적으로 쿼리 성능을 모니터링하고 분석해 개선해야 합니다.

NestJS와 TypeORM 환경에서는 ORM 기능을 정확히 이해하고 적절히 활용하는 것이 최적화의 핵심입니다.

효율적인 쿼리 설계와 인덱스 활용은 성능을 크게 높이고 사용자 경험도 개선합니다.

NestJS 쿼리 최적화는 요청 패턴, SQL 모양, 운영 지표로 판단한다

한 번 고치고 끝나는 작업이 아니라 데이터가 늘 때마다 다시 측정하고 조정하는 반복 과정입니다.

  1. Input: 요청 패턴

    목록 필터, 정렬, 페이지 크기 상세 필요 관계와 컬럼 범위 쓰기 포함 transaction 길이와 lock 범위

  2. Tools: SQL 모양

    Index scan rows 감소 Join / batch N+1 왕복 감소 Projection 전송량과 메모리 감소 Pagination 대량 조회 제한

  3. Proof: 운영 지표

    EXPLAIN 계획이 index 중심인가 Latency p95 응답 시간이 줄었는가 DB load CPU, I/O, lock wait가 줄었는가

  4. Monitor

    느린 endpoint와 SQL을 찾는다.

  5. Analyze

    실행 계획과 ORM 로그를 읽는다.

  6. Refine

    장 작은 변경을 적용한다.

  7. Repeat

    데이터 증가에 맞춰 다시 조정한다.

이것으로 9장 성능 최적화와 스케일링의 두 번째 절을 마칩니다.

다음 절에서는 애플리케이션의 응답 속도와 처리량을 높이기 위한 비동기 처리 및 논블로킹 I/O 활용에 대해 알아보겠습니다.