데이터베이스 쿼리 최적화
이제 9장의 두 번째 절로, 애플리케이션 성능 병목의 주요 원인 중 하나인 데이터베이스 쿼리 최적화에 대해 NestJS 환경을 중심으로 깊이 있게 살펴보겠습니다.
대부분의 웹 애플리케이션은 데이터를 저장하고 조회하기 위해 데이터베이스에 크게 의존합니다. 따라서 비효율적인 데이터베이스 쿼리는 전체 시스템의 성능을 저하시키는 치명적인 병목 현상을 유발할 수 있습니다. 쿼리 최적화는 단순히 코드 몇 줄을 바꾸는 것을 넘어, 데이터베이스의 작동 방식, 인덱스 활용, ORM(Object-Relational Mapping)의 특성 등 여러 요소를 종합적으로 고려해야 하는 중요한 작업입니다.
데이터베이스 쿼리 최적화의 중요성
데이터베이스 쿼리 최적화는 다음과 같은 이유로 매우 중요합니다:
- 응답 시간 단축: 쿼리 실행 시간이 줄어들면 애플리케이션의 전체 응답 시간이 직접적으로 개선됩니다.
- 서버 리소스 절약: 불필요한 데이터베이스 연산을 줄여 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 쿼리 (의사 코드)
// 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
옵션이나QueryBuilder
의joinAndSelect
를 사용하여 명시적으로 로드하는 것이 가장 유연하고 효율적인 방법입니다.
쿼리 성능 분석 도구 활용
쿼리 최적화는 단순히 코드를 수정하는 것을 넘어, 실제 쿼리가 어떻게 실행되는지 분석하는 것이 중요합니다.
-
EXPLAIN
(또는EXPLAIN ANALYZE
)- 대부분의 관계형 데이터베이스(MySQL, PostgreSQL 등)는
EXPLAIN
명령어를 제공하여 쿼리 실행 계획을 보여줍니다. - 쿼리가 어떤 인덱스를 사용하는지, 풀 스캔을 하는지, 임시 테이블을 사용하는지 등을 파악하여 비효율적인 부분을 찾아낼 수 있습니다.
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
- 대부분의 관계형 데이터베이스(MySQL, PostgreSQL 등)는
-
데이터베이스 모니터링 도구
- 데이터베이스 시스템 자체에서 제공하는 모니터링 도구(예: PostgreSQL
pg_stat_statements
, MySQL Performance Schema)나 클라우드 제공업체의 모니터링 서비스(AWS CloudWatch, GCP Cloud Monitoring)를 활용하여 느린 쿼리(Slow Query)를 식별하고, CPU/메모리/I/O 사용량 등을 추적합니다.
- 데이터베이스 시스템 자체에서 제공하는 모니터링 도구(예: PostgreSQL
-
ORM 로깅
- TypeORM 같은 ORM은 실행되는 SQL 쿼리를 콘솔에 로깅하는 기능을 제공합니다. 개발 환경에서 이를 활성화하여 N+1 쿼리나 비효율적인 쿼리를 시각적으로 파악할 수 있습니다.
// src/main.ts 또는 typeorm config TypeOrmModule.forRoot({ // ... logging: true, // SQL 쿼리 로깅 활성화 // 또는 ['query', 'error', 'schema'] 처럼 특정 로그만 선택 }),
데이터베이스 쿼리 최적화는 지속적인 과정입니다. 애플리케이션의 성장과 데이터 양의 증가에 따라 새로운 병목 현상이 발생할 수 있으므로, 주기적으로 쿼리 성능을 모니터링하고 분석하여 개선 작업을 수행해야 합니다. NestJS와 TypeORM을 사용하는 경우, ORM의 기능을 이해하고 적절히 활용하는 것이 쿼리 최적화의 핵심입니다. 효율적인 쿼리 설계와 인덱스 활용은 애플리케이션 성능을 크게 향상시키고 사용자에게 더 나은 경험을 제공할 것입니다.
이것으로 9장 "성능 최적화와 스케일링"의 두 번째 절을 마칩니다. 다음 절에서는 애플리케이션의 응답 속도와 처리량을 높이기 위한 비동기 처리 및 논블로킹 I/O 활용에 대해 알아보겠습니다. 계속해서 함께 성장해 나가시죠!