TypeORM Index·Unique 최적화

TypeORM 인덱스 설계가 중요한 이유

데이터베이스 성능의 핵심은 인덱스 설계입니다. TypeORM은 엔티티 데코레이터를 통해 단일 컬럼 인덱스, 복합 인덱스, 유니크 제약, 부분 인덱스 등 다양한 인덱스를 선언적으로 관리할 수 있습니다. 올바른 인덱스 설계는 쿼리 성능을 수십~수백 배 향상시키고, 잘못된 설계는 쓰기 성능 저하와 스토리지 낭비를 초래합니다.

단일 컬럼 인덱스: @Index

가장 기본적인 인덱스입니다. 자주 WHERE 조건이나 JOIN 키로 사용되는 컬럼에 설정합니다.

import { Entity, Column, Index, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // 컬럼 데코레이터에서 직접 인덱스
  @Index()
  @Column()
  email: string;

  // 유니크 인덱스
  @Index({ unique: true })
  @Column()
  username: string;

  // 인덱스 이름 지정 (마이그레이션 관리에 유용)
  @Index('idx_user_phone')
  @Column({ nullable: true })
  phone: string;

  @Column()
  status: string;

  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  createdAt: Date;
}

복합 인덱스: @Index on Entity

여러 컬럼을 조합한 복합 인덱스는 엔티티 클래스 레벨에서 @Index 데코레이터로 설정합니다. 컬럼 순서가 중요합니다 — 인덱스의 leftmost prefix 원칙에 따라, 왼쪽 컬럼부터 순서대로 사용해야 인덱스가 활용됩니다.

@Entity()
@Index('idx_order_user_status', ['userId', 'status'])
@Index('idx_order_created_status', ['createdAt', 'status'])
@Index('idx_order_user_created', ['userId', 'createdAt'])
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  userId: number;

  @Column()
  status: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  totalAmount: number;

  @Column({ type: 'timestamp' })
  createdAt: Date;
}

// idx_order_user_status 인덱스 활용 케이스:
// ✅ WHERE userId = 1 AND status = 'COMPLETED'  (둘 다 사용)
// ✅ WHERE userId = 1                            (leftmost prefix)
// ❌ WHERE status = 'COMPLETED'                  (왼쪽 컬럼 없음)

유니크 제약: @Unique vs @Index(unique)

유니크 제약은 데이터 무결성을 보장합니다. 단일 컬럼과 복합 컬럼 모두 설정 가능합니다.

@Entity()
@Unique('uq_member_org_user', ['organizationId', 'userId'])
@Index('idx_member_role', ['role'])
export class OrganizationMember {
  @PrimaryGeneratedColumn()
  id: number;

  // 한 조직에 같은 사용자가 중복 가입 불가
  @Column()
  organizationId: number;

  @Column()
  userId: number;

  @Column()
  role: string;

  @Column({ type: 'timestamp' })
  joinedAt: Date;
}

// 단일 컬럼 유니크: 두 가지 방법
@Entity()
export class Product {
  // 방법 1: @Column 옵션
  @Column({ unique: true })
  sku: string;

  // 방법 2: @Index(unique: true) — 이름 지정 가능
  @Index('uq_product_slug', { unique: true })
  @Column()
  slug: string;
}

부분 인덱스(Partial Index)

조건부 인덱스로, 특정 조건을 만족하는 행에만 인덱스를 생성합니다. 인덱스 크기를 줄이고 쓰기 성능을 개선합니다. PostgreSQL에서 지원됩니다.

@Entity()
// 활성 사용자만 인덱스 (PostgreSQL WHERE 절)
@Index('idx_user_active_email', ['email'], {
  where: '"status" = 'ACTIVE'',
})
// 소프트 삭제되지 않은 행만 유니크
@Index('uq_user_email_not_deleted', ['email'], {
  unique: true,
  where: '"deletedAt" IS NULL',
})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column({ default: 'ACTIVE' })
  status: string;

  @Column({ type: 'timestamp', nullable: true })
  deletedAt: Date | null;
}

// 부분 인덱스의 장점:
// 1. 인덱스 크기 감소 → 메모리 효율
// 2. INSERT/UPDATE 시 인덱스 갱신 비용 감소
// 3. Soft Delete + Unique 조합에 필수!

특히 Soft Delete 패턴에서 부분 인덱스는 필수입니다. 삭제된 행을 제외하고 유니크 제약을 적용하려면 WHERE "deletedAt" IS NULL 조건이 필요합니다.

함수 인덱스(Expression Index)

컬럼 값을 변환한 결과에 인덱스를 생성합니다. 대소문자 무시 검색, 날짜 추출 등에 유용합니다.

// TypeORM에서는 마이그레이션으로 직접 생성
// migration 파일:
export class AddExpressionIndexes1709000000000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 대소문자 무시 이메일 검색용
    await queryRunner.query(`
      CREATE INDEX idx_user_email_lower
      ON "user" (LOWER("email"))
    `);

    // 날짜 기반 파티션 조회용
    await queryRunner.query(`
      CREATE INDEX idx_order_created_date
      ON "order" (DATE("createdAt"))
    `);

    // JSON 필드 인덱스 (PostgreSQL)
    await queryRunner.query(`
      CREATE INDEX idx_user_metadata_country
      ON "user" USING gin (("metadata"->>'country'))
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query('DROP INDEX idx_user_email_lower');
    await queryRunner.query('DROP INDEX idx_order_created_date');
    await queryRunner.query('DROP INDEX idx_user_metadata_country');
  }
}

인덱스와 QueryBuilder 최적화

인덱스를 설계한 후, QueryBuilder에서 인덱스가 실제로 활용되는지 확인해야 합니다.

// ✅ 인덱스 활용: idx_order_user_status
const orders = await orderRepo
  .createQueryBuilder('order')
  .where('order.userId = :userId', { userId: 1 })
  .andWhere('order.status = :status', { status: 'COMPLETED' })
  .getMany();

// ❌ 인덱스 미활용: 함수 적용 시 인덱스 무효화
const orders = await orderRepo
  .createQueryBuilder('order')
  .where('YEAR(order.createdAt) = :year', { year: 2026 })
  .getMany();
// → YEAR() 함수가 컬럼에 적용되어 인덱스 사용 불가

// ✅ 범위 쿼리로 변환하면 인덱스 활용
const orders = await orderRepo
  .createQueryBuilder('order')
  .where('order.createdAt >= :start', { start: '2026-01-01' })
  .andWhere('order.createdAt < :end', { end: '2027-01-01' })
  .getMany();

// EXPLAIN으로 실행 계획 확인
const explain = await dataSource.query(
  'EXPLAIN ANALYZE SELECT * FROM "order" WHERE "userId" = 1 AND "status" = $1',
  ['COMPLETED']
);
console.log(explain);
// Index Scan using idx_order_user_status on order
// Index Cond: (("userId" = 1) AND (status = 'COMPLETED'))
// Execution Time: 0.054 ms

인덱스 안티패턴

안티패턴 문제 해결
모든 컬럼에 인덱스 쓰기 성능 저하, 스토리지 낭비 쿼리 패턴 분석 후 필요한 것만
중복 인덱스 (a,b) 인덱스가 있는데 (a) 단독 인덱스 추가 복합 인덱스가 leftmost prefix 커버
낮은 카디널리티 컬럼 단독 인덱스 boolean, status 등 값이 적은 컬럼 부분 인덱스 또는 복합 인덱스에 포함
복합 인덱스 컬럼 순서 오류 선택도 낮은 컬럼이 앞에 위치 선택도 높은 컬럼을 앞에 배치

마이그레이션에서 인덱스 관리

TypeORM Migration으로 인덱스를 안전하게 추가/삭제합니다. 프로덕션에서는 CONCURRENTLY 옵션으로 무중단 인덱스 생성이 가능합니다.

export class AddOrderIndexes1709000000001 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // PostgreSQL: CONCURRENTLY로 무중단 인덱스 생성
    // 주의: CONCURRENTLY는 트랜잭션 내에서 사용 불가
    await queryRunner.query(`
      CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_user_status
      ON "order" ("userId", "status")
    `);

    // 사용하지 않는 인덱스 확인 후 삭제
    // pg_stat_user_indexes에서 idx_scan = 0인 인덱스 찾기
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      DROP INDEX CONCURRENTLY IF EXISTS idx_order_user_status
    `);
  }
}

운영 베스트 프랙티스

  • 쿼리 패턴 먼저: 인덱스는 실제 쿼리 패턴에 맞게 설계하세요 — 쿼리 없이 인덱스를 미리 만들지 마세요
  • EXPLAIN 검증: 인덱스 추가 후 반드시 EXPLAIN ANALYZE로 실행 계획을 확인하세요
  • 복합 인덱스 순서: 선택도(cardinality)가 높은 컬럼을 앞에, 범위 조건 컬럼을 뒤에 배치하세요
  • Soft Delete + Unique: 부분 인덱스로 WHERE "deletedAt" IS NULL 조건을 추가하세요
  • CONCURRENTLY: 프로덕션에서는 무중단 인덱스 생성을 사용하세요
  • 미사용 인덱스 정리: pg_stat_user_indexes에서 idx_scan = 0인 인덱스를 주기적으로 확인하고 삭제하세요
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux