왜 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 드라이버가 문자열로 반환하므로 DBType은 string입니다.
엔티티에서 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 값 사이의 변환을 한 곳에 캡슐화하여, 엔티티 코드에서는 비즈니스 타입만 다루고 저장소 세부사항을 감춥니다. convertToDatabaseValue와 convertToJSValue에 null guard를 포함하고, compareAsType으로 변경 감지 성능을 최적화하며, getColumnType에서 platform 분기를 고려하면 실무에서 안전하게 사용할 수 있습니다.
참고: MikroORM 공식 문서 — Custom Types, Configuration — Mapping Types