ViewEntity란?
TypeORM의 @ViewEntity는 데이터베이스 뷰(View)를 ORM 엔티티처럼 매핑하는 기능이다. 복잡한 JOIN, 집계, 서브쿼리 결과를 읽기 전용 엔티티로 정의해 Repository에서 바로 조회할 수 있다. 뷰를 사용하면 복잡한 쿼리를 DB 레벨에 캡슐화하고, 애플리케이션 코드는 단순한 find 호출만 하면 된다.
기본 ViewEntity 정의
import { ViewEntity, ViewColumn, DataSource } from 'typeorm';
// 주문 요약 뷰: 사용자별 주문 통계
@ViewEntity({
name: 'order_summary_view',
expression: (dataSource: DataSource) =>
dataSource
.createQueryBuilder()
.select('user.id', 'userId')
.addSelect('user.name', 'userName')
.addSelect('COUNT(order.id)', 'totalOrders')
.addSelect('SUM(order.amount)', 'totalAmount')
.addSelect('AVG(order.amount)', 'avgAmount')
.addSelect('MAX(order.createdAt)', 'lastOrderAt')
.from('user', 'user')
.leftJoin('order', 'order', 'order.userId = user.id')
.groupBy('user.id')
.addGroupBy('user.name'),
})
export class OrderSummaryView {
@ViewColumn()
userId: number;
@ViewColumn()
userName: string;
@ViewColumn()
totalOrders: number;
@ViewColumn()
totalAmount: number;
@ViewColumn()
avgAmount: number;
@ViewColumn()
lastOrderAt: Date;
}
expression은 QueryBuilder 또는 문자열 SQL로 정의할 수 있다. QueryBuilder를 사용하면 타입 안전성이 높아지고, 문자열은 DB 전용 함수를 직접 쓸 수 있다.
문자열 SQL로 정의
복잡한 윈도우 함수나 DB 전용 구문이 필요하면 문자열이 더 유연하다.
@ViewEntity({
name: 'product_ranking_view',
expression: `
SELECT
p.id AS "productId",
p.name AS "productName",
p.category_id AS "categoryId",
c.name AS "categoryName",
COUNT(oi.id) AS "salesCount",
SUM(oi.quantity * oi.unit_price) AS "revenue",
RANK() OVER (
PARTITION BY p.category_id
ORDER BY SUM(oi.quantity * oi.unit_price) DESC
) AS "categoryRank"
FROM product p
JOIN category c ON c.id = p.category_id
LEFT JOIN order_item oi ON oi.product_id = p.id
LEFT JOIN "order" o ON o.id = oi.order_id
AND o.status = 'completed'
GROUP BY p.id, p.name, p.category_id, c.name
`,
})
export class ProductRankingView {
@ViewColumn()
productId: number;
@ViewColumn()
productName: string;
@ViewColumn()
categoryId: number;
@ViewColumn()
categoryName: string;
@ViewColumn()
salesCount: number;
@ViewColumn()
revenue: number;
@ViewColumn()
categoryRank: number;
}
NestJS에서 사용하기
ViewEntity도 일반 엔티티와 동일하게 Module에 등록하고 Repository로 조회한다.
// app.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([
User,
Order,
OrderSummaryView, // ViewEntity 등록
ProductRankingView,
]),
],
})
export class AppModule {}
// service
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(OrderSummaryView)
private readonly orderSummaryRepo: Repository<OrderSummaryView>,
@InjectRepository(ProductRankingView)
private readonly productRankingRepo: Repository<ProductRankingView>,
) {}
// 일반 find처럼 사용
async getUserOrderSummary(userId: number) {
return this.orderSummaryRepo.findOneBy({ userId });
}
// where, order, pagination 모두 지원
async getTopProducts(categoryId: number, limit: number) {
return this.productRankingRepo.find({
where: { categoryId },
order: { categoryRank: 'ASC' },
take: limit,
});
}
// QueryBuilder도 사용 가능
async getHighValueUsers(minAmount: number) {
return this.orderSummaryRepo
.createQueryBuilder('summary')
.where('summary.totalAmount > :min', { min: minAmount })
.orderBy('summary.totalAmount', 'DESC')
.getMany();
}
}
ViewEntity의 최대 장점은 복잡한 쿼리를 캡슐화하면서도 TypeORM의 find 옵션(where, order, take, skip)을 그대로 쓸 수 있다는 점이다.
Materialized View 패턴
일반 뷰는 매 조회마다 쿼리를 실행한다. 대용량 데이터에서는 Materialized View로 결과를 물리적으로 저장해 성능을 높인다. TypeORM은 Materialized View를 직접 지원하지 않지만, 마이그레이션으로 구현할 수 있다.
// migration
export class CreateMaterializedView1709600000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
// PostgreSQL Materialized View
await queryRunner.query(`
CREATE MATERIALIZED VIEW order_summary_mv AS
SELECT
u.id AS "userId",
u.name AS "userName",
COUNT(o.id) AS "totalOrders",
COALESCE(SUM(o.amount), 0) AS "totalAmount",
COALESCE(AVG(o.amount), 0) AS "avgAmount",
MAX(o.created_at) AS "lastOrderAt"
FROM "user" u
LEFT JOIN "order" o ON o.user_id = u.id
GROUP BY u.id, u.name
WITH DATA
`);
// 인덱스 추가 (Materialized View에서 가능)
await queryRunner.query(`
CREATE UNIQUE INDEX idx_order_summary_mv_user
ON order_summary_mv ("userId")
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP MATERIALIZED VIEW IF EXISTS order_summary_mv');
}
}
// ViewEntity는 동일하게 매핑
@ViewEntity({
name: 'order_summary_mv',
expression: `SELECT * FROM order_summary_mv`, // 이미 물리 뷰 존재
// 또는 synchronize: false로 DDL 비활성화
})
export class OrderSummaryMV { ... }
// 주기적 새로고침 (NestJS Cron)
@Injectable()
export class MaterializedViewRefresher {
constructor(private dataSource: DataSource) {}
@Cron(CronExpression.EVERY_HOUR)
async refreshOrderSummary() {
// CONCURRENTLY: 새로고침 중에도 읽기 가능 (UNIQUE INDEX 필요)
await this.dataSource.query(
'REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary_mv'
);
}
}
ViewEntity vs QueryBuilder vs Raw Query
| 방식 | 장점 | 단점 | 추천 상황 |
|---|---|---|---|
| ViewEntity | 타입 안전, find 호환 | 읽기 전용, DDL 필요 | 반복 사용되는 복잡 조회 |
| QueryBuilder | 동적 쿼리, 유연함 | 복잡해지면 가독성 저하 | 동적 필터링 |
| Raw Query | DB 기능 100% 활용 | 타입 안전성 없음 | DB 전용 최적화 |
| Materialized View | 사전 계산, 극한 성능 | 데이터 지연, 저장 공간 | 대시보드, 통계 |
실전 패턴: 대시보드 API
// 여러 ViewEntity를 조합한 대시보드
@Injectable()
export class DashboardService {
constructor(
@InjectRepository(OrderSummaryView)
private orderSummary: Repository<OrderSummaryView>,
@InjectRepository(ProductRankingView)
private productRanking: Repository<ProductRankingView>,
) {}
async getDashboard(userId: number) {
const [userStats, topProducts] = await Promise.all([
this.orderSummary.findOneBy({ userId }),
this.productRanking.find({
order: { revenue: 'DESC' },
take: 10,
}),
]);
return { userStats, topProducts };
}
}
주의사항
1. 읽기 전용: ViewEntity에 save/update/delete를 호출하면 에러가 발생한다. Repository<ViewEntity>에서 find 계열만 사용하자.
2. synchronize 옵션: synchronize: true(기본값)면 TypeORM이 앱 시작 시 뷰를 자동 생성/업데이트한다. 프로덕션에서는 마이그레이션으로 관리하고 synchronize: false를 권장한다.
3. 성능: 일반 뷰는 매번 서브쿼리를 실행한다. 대용량 테이블에서는 Materialized View나 캐싱을 고려하자. 다계층 캐시 전략과 조합하면 효과적이다.
정리
TypeORM ViewEntity는 복잡한 읽기 쿼리를 DB 뷰로 캡슐화하고, ORM의 편의성을 그대로 유지하는 강력한 도구다. 대시보드, 통계, 리포트처럼 반복 사용되는 복잡 조회에 적합하다. Materialized View와 Cron 새로고침을 조합하면 대용량 데이터에서도 빠른 응답 속도를 확보할 수 있다.