Prisma Computed Field 가상 필드

Prisma에 Computed Field가 필요한 이유

데이터베이스에는 firstNamelastName이 따로 저장되지만, API 응답에는 fullName이 필요합니다. 나이(age)는 birthDate에서 계산하고, isActive는 마지막 로그인 시간으로 판단합니다. 이런 가상 필드를 매번 서비스 레이어에서 수동으로 붙이면 코드 중복이 발생합니다.

Prisma 4.16+에서 도입된 Client Extensionsresult 확장을 사용하면, 쿼리 결과에 자동으로 계산 필드를 추가할 수 있습니다.

기본 Computed Field 구현

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient().$extends({
  result: {
    user: {
      fullName: {
        needs: { firstName: true, lastName: true },
        compute(user) {
          return `${user.firstName} ${user.lastName}`;
        },
      },
      age: {
        needs: { birthDate: true },
        compute(user) {
          const today = new Date();
          const birth = new Date(user.birthDate);
          let age = today.getFullYear() - birth.getFullYear();
          const monthDiff = today.getMonth() - birth.getMonth();
          if (monthDiff < 0 || (monthDiff === 0 &&
              today.getDate() < birth.getDate())) {
            age--;
          }
          return age;
        },
      },
    },
  },
});

needs는 이 계산에 필요한 실제 DB 컬럼을 선언합니다. Prisma는 select에서 fullName을 요청하면 자동으로 firstNamelastName을 함께 조회합니다.

// 사용
const user = await prisma.user.findUnique({
  where: { id: 1 },
});
console.log(user.fullName); // "김 철수"
console.log(user.age);      // 28

// select와 함께 사용
const users = await prisma.user.findMany({
  select: {
    fullName: true,  // computed field
    email: true,     // 일반 필드
  },
});
// fullName 계산에 필요한 firstName, lastName은 자동 조회

관계 기반 Computed Field

다른 모델과의 관계를 활용한 계산 필드도 구현할 수 있습니다.

const prisma = new PrismaClient().$extends({
  result: {
    order: {
      totalAmount: {
        needs: { id: true },
        compute(order) {
          // 주의: 이 방식은 N+1 문제 발생 가능
          // 관계 데이터가 필요하면 include와 함께 사용
          return undefined; // 아래 방식 권장
        },
      },
    },
  },
});

// 권장: include된 데이터 기반 계산
const prismaWithComputed = new PrismaClient().$extends({
  result: {
    order: {
      totalAmount: {
        needs: { id: true },
        compute(order) {
          // @ts-ignore - include로 로드된 관계
          if (!order.items) return null;
          return order.items.reduce(
            (sum, item) => sum + item.price * item.quantity, 0
          );
        },
      },
      itemCount: {
        needs: { id: true },
        compute(order) {
          if (!order.items) return null;
          return order.items.length;
        },
      },
    },
  },
});

// 사용 시 반드시 include
const order = await prismaWithComputed.order.findUnique({
  where: { id: 1 },
  include: { items: true },
});
console.log(order.totalAmount); // 45000
console.log(order.itemCount);   // 3

타입 안전한 Computed Field

Prisma Client Extensions는 TypeScript 타입을 자동으로 추론합니다.

const prisma = new PrismaClient().$extends({
  result: {
    user: {
      isActive: {
        needs: { lastLoginAt: true },
        compute(user): boolean {
          if (!user.lastLoginAt) return false;
          const thirtyDaysAgo = new Date();
          thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
          return user.lastLoginAt > thirtyDaysAgo;
        },
      },
      displayName: {
        needs: { firstName: true, lastName: true, nickname: true },
        compute(user): string {
          return user.nickname || `${user.firstName} ${user.lastName}`;
        },
      },
      maskedEmail: {
        needs: { email: true },
        compute(user): string {
          const [local, domain] = user.email.split('@');
          const masked = local.slice(0, 2) + '***';
          return `${masked}@${domain}`;
        },
      },
    },
  },
});

// TypeScript가 타입을 자동 추론
const user = await prisma.user.findFirst();
user.isActive;     // boolean
user.displayName;  // string
user.maskedEmail;  // string — "ki***@gmail.com"

여러 Extension 조합: $extends 체이닝

관심사별로 Extension을 분리하고 체이닝할 수 있습니다.

// extensions/user-computed.ts
export const userComputedExtension = {
  result: {
    user: {
      fullName: {
        needs: { firstName: true, lastName: true },
        compute: (user) => `${user.firstName} ${user.lastName}`,
      },
    },
  },
} as const;

// extensions/audit-computed.ts
export const auditComputedExtension = {
  result: {
    user: {
      isActive: {
        needs: { lastLoginAt: true },
        compute: (user) => {
          if (!user.lastLoginAt) return false;
          const cutoff = new Date();
          cutoff.setDate(cutoff.getDate() - 30);
          return user.lastLoginAt > cutoff;
        },
      },
    },
  },
} as const;

// prisma.ts — 조합
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient()
  .$extends(userComputedExtension)
  .$extends(auditComputedExtension);

// 모든 computed field 사용 가능
const user = await prisma.user.findFirst();
user.fullName;  // ✅
user.isActive;  // ✅

NestJS 통합 패턴

NestJS에서 Prisma Client Extensions를 DI와 통합하는 패턴입니다.

// prisma.module.ts
@Module({
  providers: [
    {
      provide: 'PRISMA_CLIENT',
      useFactory: () => {
        return new PrismaClient().$extends({
          result: {
            user: {
              fullName: {
                needs: { firstName: true, lastName: true },
                compute: (u) => `${u.firstName} ${u.lastName}`,
              },
              age: {
                needs: { birthDate: true },
                compute: (u) => {
                  const diff = Date.now() - u.birthDate.getTime();
                  return Math.floor(diff / 31557600000);
                },
              },
            },
          },
        });
      },
    },
  ],
  exports: ['PRISMA_CLIENT'],
})
export class PrismaModule {}

// user.service.ts
@Injectable()
export class UserService {
  constructor(
    @Inject('PRISMA_CLIENT')
    private readonly prisma: ReturnType<typeof createPrismaClient>,
  ) {}

  async getProfile(id: number) {
    return this.prisma.user.findUnique({
      where: { id },
      // fullName, age 자동 포함
    });
  }
}

성능 고려사항

  • needs 최소화: 불필요한 컬럼을 needs에 포함하면 SELECT 쿼리가 무거워짐
  • 비동기 불가: compute 함수는 동기 전용 — DB 쿼리나 API 호출 불가
  • N+1 주의: 관계 기반 계산은 반드시 include와 함께 사용
  • 캐싱 없음: 매 조회마다 compute가 실행됨 — 무거운 계산은 DB computed column 권장
// ❌ 안티패턴: 무거운 계산
compute(user) {
  // 동기 함수에서 복잡한 연산 수행
  return heavyCalculation(user.data); // 수만 행이면 성능 저하
}

// ✅ 권장: DB 레벨 computed column + Prisma
// schema.prisma — DB generated column 활용
model Product {
  price    Decimal
  tax      Decimal
  // total은 DB에서 GENERATED ALWAYS AS (price + tax)
  total    Decimal  @default(0) // DB computed column
}

관련 글

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux