NestJS + MikroORM Custom Types

왜 Custom Type이 필요한가: 기본 타입 매핑의 한계

MikroORM은 string, number, boolean, Date 등 기본 타입을 자동으로 DB 컬럼에 매핑합니다. 하지만 실무에서는 이 범위를 벗어나는 경우가 빈번합니다: PostgreSQL의 tsrange(시간 범위)를 JS 객체로 변환하거나, bigint 컬럼을 JavaScript의 BigInt로 매핑하거나, 암호화된 컬럼을 자동 복호화하는 등의 작업이 필요합니다. MikroORM 공식 문서(Custom Types 섹션)에서는 Type 추상 클래스를 상속하여 DB ↔ JS 간의 변환 로직을 선언적으로 정의하는 방법을 제공합니다.

Custom Type의 구조: Type 추상 클래스

MikroORM의 Custom Type은 Type 클래스를 상속하여 만듭니다. 공식 문서에 따르면 핵심 메서드는 다음 네 가지입니다:

메서드 방향 용도
convertToDatabaseValue(value, platform) JS → DB 엔티티 값을 DB에 저장할 형태로 변환
convertToJSValue(value, platform) DB → JS DB에서 읽은 값을 JS 객체로 변환
getColumnType(prop, platform) 스키마 DB 컬럼 타입 문자열 반환 (CREATE TABLE 시 사용)
compareAsType() 변경 감지 ChangeSet 비교 시 사용할 타입 힌트
import { Type, Platform, EntityProperty } from '@mikro-orm/core';

export class BigIntType extends Type<bigint, string> {

  convertToDatabaseValue(value: bigint, platform: Platform): string {
    return value.toString();
  }

  convertToJSValue(value: string, platform: Platform): bigint {
    return BigInt(value);
  }

  getColumnType(prop: EntityProperty, platform: Platform): string {
    return 'bigint';
  }

  compareAsType(): string {
    return 'string';
  }
}

제네릭 파라미터 Type<JSType, DBType>에서 첫 번째는 JavaScript에서 사용할 타입, 두 번째는 DB 드라이버가 반환하는 타입입니다. bigint 컬럼의 경우 대부분의 DB 드라이버가 문자열로 반환하므로 DBTypestring입니다.

엔티티에서 Custom Type 사용하기

import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { BigIntType } from './types/bigint.type';

@Entity()
export class WalletTransaction {
  @PrimaryKey()
  id!: number;

  @Property({ type: BigIntType })
  amountWei!: bigint;

  @Property({ type: BigIntType })
  gasUsed!: bigint;
}

@Property({ type: BigIntType })로 Custom Type을 지정하면, 해당 프로퍼티에 대한 모든 DB 읽기/쓰기에서 자동으로 변환 메서드가 호출됩니다. Schema Generator도 getColumnType()의 반환값을 사용합니다.

실무 예제 1: 암호화 컬럼 타입

개인정보 보호를 위해 특정 컬럼을 AES-256으로 암호화하여 저장하는 Custom Type입니다.

import { Type, Platform } from '@mikro-orm/core';
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';

export class EncryptedType extends Type<string, string> {
  private readonly algorithm = 'aes-256-gcm';
  private readonly key: Buffer;

  constructor() {
    super();
    const secret = process.env.ENCRYPTION_KEY;
    if (!secret) throw new Error('ENCRYPTION_KEY env required');
    this.key = scryptSync(secret, 'salt', 32);
  }

  convertToDatabaseValue(value: string, platform: Platform): string {
    if (!value) return value;
    const iv = randomBytes(16);
    const cipher = createCipheriv(this.algorithm, this.key, iv);
    let encrypted = cipher.update(value, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    const authTag = cipher.getAuthTag().toString('hex');
    // iv:authTag:encrypted 형태로 저장
    return `${iv.toString('hex')}:${authTag}:${encrypted}`;
  }

  convertToJSValue(value: string, platform: Platform): string {
    if (!value || !value.includes(':')) return value;
    const [ivHex, authTagHex, encrypted] = value.split(':');
    const decipher = createDecipheriv(
      this.algorithm,
      this.key,
      Buffer.from(ivHex, 'hex'),
    );
    decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
    let decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
  }

  getColumnType(): string {
    return 'text';
  }
}

// 사용
@Entity()
export class UserProfile {
  @PrimaryKey()
  id!: number;

  @Property({ type: EncryptedType })
  socialSecurityNumber!: string; // JS에서는 평문, DB에는 암호화
}

실무 예제 2: 콤마 구분 Enum Set 타입

MySQL의 SET 타입 대신, 범용적으로 enum 배열을 콤마로 구분된 문자열로 저장하는 패턴입니다.

export enum Permission {
  READ = 'READ',
  WRITE = 'WRITE',
  DELETE = 'DELETE',
  ADMIN = 'ADMIN',
}

export class EnumSetType extends Type<Permission[], string> {

  convertToDatabaseValue(value: Permission[], platform: Platform): string {
    if (!value || !Array.isArray(value)) return '';
    return [...new Set(value)].sort().join(',');
  }

  convertToJSValue(value: string, platform: Platform): Permission[] {
    if (!value) return [];
    return value.split(',').filter(Boolean) as Permission[];
  }

  getColumnType(): string {
    return 'varchar(255)';
  }

  compareAsType(): string {
    return 'string';
  }
}

@Entity()
export class Role {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @Property({ type: EnumSetType })
  permissions!: Permission[];
}

DB에는 "ADMIN,DELETE,READ" 형태로 저장되고, JS에서는 [Permission.ADMIN, Permission.DELETE, Permission.READ] 배열로 사용됩니다.

실무 예제 3: PostgreSQL tsrange (시간 범위)

export interface DateRange {
  start: Date;
  end: Date;
}

export class DateRangeType extends Type<DateRange, string> {

  convertToDatabaseValue(value: DateRange, platform: Platform): string {
    if (!value) return value as any;
    return `[${value.start.toISOString()},${value.end.toISOString()})`;
  }

  convertToJSValue(value: string, platform: Platform): DateRange {
    if (!value) return value as any;
    // PostgreSQL tsrange 형식: [start,end) or (start,end]
    const cleaned = value.replace(/[\[\]\(\)]/g, '');
    const [start, end] = cleaned.split(',');
    return {
      start: new Date(start),
      end: new Date(end),
    };
  }

  getColumnType(): string {
    return 'tsrange';
  }

  compareAsType(): string {
    return 'any';
  }
}

@Entity()
export class Event {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;

  @Property({ type: DateRangeType })
  period!: DateRange;
}

NestJS에서 Custom Type 등록과 사용

MikroORM의 Custom Type은 별도 등록 절차가 필요 없습니다. @Property({ type: MyCustomType })으로 엔티티에 선언하면 자동으로 인식됩니다. 그러나 전역적으로 재사용하려면 MikroORM 설정의 discovery.getMappedType을 활용할 수 있습니다.

// mikro-orm.config.ts
import { defineConfig, Platform, Type } from '@mikro-orm/core';

export default defineConfig({
  // ...
  discovery: {
    getMappedType(type: string, platform: Platform): Type<unknown> | undefined {
      // DB 컬럼 타입 'bigint'를 자동으로 BigIntType에 매핑
      if (type === 'bigint') {
        return new BigIntType();
      }
      return undefined; // 기본 매핑 사용
    },
  },
});

이 설정으로 별도 type 옵션 없이 DB에서 bigint 컬럼을 발견하면 자동으로 BigIntType이 적용됩니다.

변경 감지(Change Detection)와 compareAsType

MikroORM의 Unit of Work는 flush 시 엔티티의 변경 여부를 감지합니다. Custom Type에서는 compareAsType() 메서드가 비교 방식을 결정합니다.

compareAsType 반환값 비교 방식 적합한 경우
'string' 문자열 단순 비교 BigInt, Enum Set 등 직렬화 결과로 비교 가능
'number' 숫자 비교 타임스탬프 등
'boolean' 불리언 비교 플래그 변환
'any' JSON.stringify 후 비교 객체, 배열 등 복합 타입
'Buffer' Buffer 비교 바이너리 데이터

함정: compareAsType()을 생략하면 기본값은 'any'로, 매 flush마다 JSON.stringify로 비교합니다. 단순 타입에는 성능상 불필요한 오버헤드이므로 적절한 타입을 명시하는 것이 좋습니다.

ensureComparable: 변경 감지 커스터마이징

MikroORM v5.6+에서는 ensureComparable() 메서드를 통해 비교 전 값을 정규화할 수 있습니다. 예를 들어 Date 객체를 ISO 문자열로 정규화하여 동일한 시각의 서로 다른 Date 인스턴스를 같은 값으로 판단하게 할 수 있습니다.

export class DateRangeType extends Type<DateRange, string> {
  // ... (위의 변환 메서드들)

  ensureComparable(value: DateRange): string {
    if (!value) return '';
    return `${value.start.toISOString()}-${value.end.toISOString()}`;
  }
}

흔한 실수와 운영 체크리스트

실수 1: convertToJSValue에서 null/undefined 미처리

nullable 컬럼에서 null이 전달될 수 있습니다. 변환 메서드 시작에 반드시 null guard를 추가하세요.

// ❌ null이 전달되면 에러
convertToJSValue(value: string): bigint {
  return BigInt(value); // TypeError: Cannot convert null to a BigInt
}

// ✅ null guard 추가
convertToJSValue(value: string | null): bigint | null {
  if (value === null || value === undefined) return null as any;
  return BigInt(value);
}

실수 2: getColumnType의 DB 호환성

getColumnType()에서 PostgreSQL 전용 타입(예: tsrange)을 반환하면 MySQL에서 사용할 수 없습니다. 다중 DB를 지원해야 하면 platform 파라미터로 분기하세요.

getColumnType(prop: EntityProperty, platform: Platform): string {
  if (platform.constructor.name === 'PostgreSqlPlatform') {
    return 'tsrange';
  }
  return 'varchar(255)'; // 다른 DB에서는 문자열로 저장
}

실수 3: Custom Type에서 부수효과 발생

변환 메서드는 순수 함수여야 합니다. HTTP 호출, 파일 I/O 등의 부수효과를 넣으면 매 조회/저장마다 실행되어 심각한 성능 문제가 발생합니다. 부수효과는 Subscriber나 서비스 계층에서 처리하세요.

실수 4: Custom Type과 QueryBuilder 필터링

em.find()에서 Custom Type 프로퍼티로 필터링할 때, 전달하는 값은 JS 타입이어야 합니다. MikroORM이 자동으로 convertToDatabaseValue를 호출합니다.

// ✅ JS 타입으로 전달 — MikroORM이 BigIntType.convertToDatabaseValue 호출
const txs = await em.find(WalletTransaction, {
  amountWei: { $gte: BigInt('1000000000000000000') },
});

정리: Custom Type 설계 판단 기준

요구사항 권장 접근 이유
단순 형변환 (string → number) 기본 type 옵션 Custom Type 불필요
BigInt, Decimal 정밀 변환 Custom Type 드라이버 반환 타입과 JS 타입 불일치
암호화/복호화 Custom Type 투명한 양방향 변환
DB 전용 타입 (tsrange, geometry) Custom Type + platform 분기 DB 종속 타입 캡슐화
복합 객체 ↔ 단일 컬럼 Custom Type (compareAsType=’any’) 직렬화 + 변경 감지 통합

MikroORM의 Custom Type은 DB 컬럼과 JS 값 사이의 변환을 한 곳에 캡슐화하여, 엔티티 코드에서는 비즈니스 타입만 다루고 저장소 세부사항을 감춥니다. convertToDatabaseValueconvertToJSValue에 null guard를 포함하고, compareAsType으로 변경 감지 성능을 최적화하며, getColumnType에서 platform 분기를 고려하면 실무에서 안전하게 사용할 수 있습니다.

참고: MikroORM 공식 문서 — Custom Types, Configuration — Mapping Types

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