왜 컬럼 타입 설계가 중요한가
NestJS + TypeORM 프로젝트에서 @Column()은 가장 많이 쓰는 데코레이터입니다. 그러나 기본 string, number만 쓰다 보면 운영에서 다양한 문제를 만납니다: 암호화된 값을 매번 수동 변환, Enum 값이 DB에 숫자로 저장되어 디버깅 불가, JSON 컬럼의 Change Detection 실패 등.
TypeORM은 이를 해결하기 위해 ValueTransformer, Enum 컬럼, JSON/JSONB 컬럼을 제공합니다. 이 글은 공식 문서(Column Types, Column Types for MySQL)를 근거로 각각의 동작 원리, NestJS 실무 패턴, 그리고 흔한 함정을 정리합니다.
ValueTransformer: DB ↔ 애플리케이션 값 자동 변환
TypeORM의 ValueTransformer 인터페이스는 두 메서드로 구성됩니다:
to(value): 애플리케이션 → DB 저장 시 변환from(value): DB → 애플리케이션 로딩 시 변환
패턴 1: 암호화/복호화 Transformer
import { ValueTransformer } from 'typeorm';
import * as crypto from 'crypto';
const ALGORITHM = 'aes-256-cbc';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); // 32 bytes
const IV_LENGTH = 16;
export class EncryptionTransformer implements ValueTransformer {
to(value: string | null): string | null {
if (!value) return value;
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
from(value: string | null): string | null {
if (!value) return value;
const [ivHex, encryptedHex] = value.split(':');
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
return decipher.update(Buffer.from(encryptedHex, 'hex'), undefined, 'utf8')
+ decipher.final('utf8');
}
}
// Entity에서 사용
@Entity()
export class User {
@Column({ transformer: new EncryptionTransformer() })
socialSecurityNumber: string;
// DB에는 암호화된 문자열 저장, 코드에서는 평문으로 접근
}
패턴 2: BigInt(문자열 ↔ 숫자) Transformer
MySQL의 BIGINT는 JavaScript의 number 범위를 초과할 수 있습니다. TypeORM은 BIGINT를 문자열로 반환하는데, 이를 BigInt 또는 number로 자동 변환합니다.
export class BigIntTransformer implements ValueTransformer {
to(value: number): number {
return value;
}
from(value: string): number {
return parseInt(value, 10);
}
}
@Entity()
export class Transaction {
@Column({ type: 'bigint', transformer: new BigIntTransformer() })
amount: number; // DB에서 문자열로 오지만 자동으로 number 변환
}
패턴 3: Boolean Transformer (MySQL tinyint)
MySQL은 BOOLEAN을 TINYINT(1)로 저장합니다. TypeORM은 대부분 자동 변환하지만, Raw Query나 특정 드라이버에서 0/1이 그대로 올 수 있습니다.
export class BooleanTransformer implements ValueTransformer {
to(value: boolean | null): number | null {
return value === null ? null : value ? 1 : 0;
}
from(value: number | null): boolean | null {
return value === null ? null : value === 1;
}
}
복수 Transformer 적용
TypeORM 공식 문서에 명시된 대로, transformer 옵션에 배열을 전달하면 순차 적용됩니다.
@Column({
transformer: [new TrimTransformer(), new LowerCaseTransformer()],
})
email: string;
// to: trim → lowercase → DB 저장
// from: lowercase → trim → 애플리케이션 (역순)
Enum 컬럼: 문자열 vs 숫자 저장
TypeORM은 TypeScript enum을 DB 컬럼으로 매핑할 때 두 가지 방식을 지원합니다.
문자열 Enum (권장)
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
}
@Entity()
export class Order {
@Column({ type: 'enum', enum: OrderStatus, default: OrderStatus.PENDING })
status: OrderStatus;
// MySQL: ENUM('pending','confirmed','shipped','delivered','cancelled')
}
장점: DB에서 직접 조회할 때 값이 읽힙니다. 디버깅이 쉽습니다.
숫자 Enum (주의 필요)
export enum Priority {
LOW = 0,
MEDIUM = 1,
HIGH = 2,
CRITICAL = 3,
}
@Entity()
export class Task {
@Column({ type: 'enum', enum: Priority, default: Priority.LOW })
priority: Priority;
// MySQL: ENUM('0','1','2','3') — 문자열 '0', '1'로 저장됨
}
주의: MySQL의 ENUM은 항상 문자열입니다. 숫자 enum의 값 0, 1은 문자열 '0', '1'로 저장됩니다. 이로 인해 WHERE priority = 0 같은 쿼리에서 타입 불일치가 발생할 수 있습니다.
PostgreSQL의 경우: enum 대신 varchar 고려
PostgreSQL은 네이티브 ENUM 타입을 지원하지만, 값을 추가하는 것은 간단하지만 제거/수정이 어렵습니다. Migration에서 ALTER TYPE ... ADD VALUE는 트랜잭션 안에서 실행할 수 없는(PostgreSQL 12 미만) 제약이 있습니다. 이런 이유로 많은 팀에서 varchar + 애플리케이션 레벨 검증을 선택합니다.
// PostgreSQL에서 안전한 대안
@Column({ type: 'varchar', length: 20 })
status: OrderStatus;
// DB에는 varchar로 저장, 타입 체크는 TypeScript enum으로
JSON/JSONB 컬럼: 유연함의 대가
TypeORM에서 JSON 컬럼은 simple-json 또는 네이티브 json/jsonb(PostgreSQL) 타입으로 사용합니다.
simple-json vs json/jsonb
| 항목 | simple-json | json (MySQL) | jsonb (PostgreSQL) |
|---|---|---|---|
| DB 저장 형태 | TEXT (JSON.stringify) | 네이티브 JSON | 네이티브 JSONB (바이너리) |
| 인덱싱 | ❌ 불가 | 제한적 (Generated Column) | ✅ GIN 인덱스 가능 |
| 쿼리 내 검색 | LIKE만 가능 | JSON_EXTRACT 가능 | @>, ->> 연산자 가능 |
| 적합 용도 | 소규모 설정값 | 구조화된 데이터 | 검색 필요한 비정형 데이터 |
사용 예시
// simple-json: TEXT 컬럼에 JSON.stringify로 저장
@Column({ type: 'simple-json', nullable: true })
preferences: { theme: string; language: string } | null;
// 네이티브 json (MySQL)
@Column({ type: 'json', nullable: true })
metadata: Record<string, any>;
// jsonb (PostgreSQL)
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
자주 빠지는 함정 4가지
함정 1: JSON 컬럼의 Change Detection 실패
TypeORM의 save()는 값 비교로 변경 여부를 판단합니다. JSON 객체는 참조(reference)가 같으면 변경을 감지하지 못합니다.
// ❌ Change Detection 실패 — UPDATE 실행 안 됨
const user = await userRepo.findOneBy({ id: 1 });
user.preferences.theme = 'dark'; // 같은 객체의 속성만 변경
await userRepo.save(user); // TypeORM이 변경을 감지하지 못함
// ✅ 새 객체로 재할당
const user = await userRepo.findOneBy({ id: 1 });
user.preferences = { ...user.preferences, theme: 'dark' }; // 새 객체
await userRepo.save(user); // UPDATE 실행됨
이는 TypeORM GitHub 이슈에서 반복적으로 보고되는 알려진 동작입니다. JSON 컬럼 수정 시 반드시 스프레드 연산자로 새 객체를 생성해야 합니다.
함정 2: ValueTransformer의 to()에 null/undefined가 올 수 있음
// ❌ null 체크 누락
export class EncryptionTransformer implements ValueTransformer {
to(value: string): string {
// value가 null이면 crypto 에러 발생!
const cipher = crypto.createCipheriv(...);
return cipher.update(value, 'utf8', 'hex');
}
}
// ✅ null/undefined 방어
export class EncryptionTransformer implements ValueTransformer {
to(value: string | null | undefined): string | null {
if (value == null) return null; // null/undefined 통과
// ... 암호화 로직
}
}
nullable 컬럼이나 optional 필드에서 to()/from()에 null이 전달됩니다. 모든 Transformer에 null 체크는 필수입니다.
함정 3: MySQL ENUM 변경 시 Migration 위험
MySQL에서 ENUM 값을 추가/제거하면 TypeORM Migration이 ALTER TABLE ... CHANGE COLUMN을 생성합니다. 이는 대형 테이블에서 테이블 잠금을 유발할 수 있습니다.
// Migration이 생성하는 SQL
ALTER TABLE `order` CHANGE `status` `status`
ENUM('pending','confirmed','shipped','delivered','cancelled','refunded')
NOT NULL DEFAULT 'pending';
// MySQL 5.7: 테이블 복사 발생 → 대형 테이블에서 수 분~수 시간 잠금
// MySQL 8.0+: INSTANT ADD는 ENUM 끝에 추가하는 경우만 지원
대책: ENUM 값 추가는 항상 기존 값 목록의 끝에 추가하세요. MySQL 8.0+에서는 끝에 추가하는 경우 INSTANT ALTER가 가능합니다. 중간 삽입이나 제거가 필요하면 pt-online-schema-change 같은 도구를 사용하세요.
함정 4: Transformer가 QueryBuilder의 where에서 적용되지 않음
ValueTransformer는 Entity의 save()/find()에서만 자동 적용됩니다. QueryBuilder의 where()에서 직접 값을 넘기면 Transformer의 to()가 호출되지 않습니다.
// 암호화 Transformer가 적용된 컬럼
@Column({ transformer: new EncryptionTransformer() })
socialSecurityNumber: string;
// ❌ Transformer 미적용 — 평문으로 검색됨
const user = await userRepo
.createQueryBuilder('user')
.where('user.socialSecurityNumber = :ssn', { ssn: '123-45-6789' })
.getOne();
// DB에는 암호화된 값이 저장되어 있으므로 일치하는 행 없음
// ✅ find() 사용 — Transformer 자동 적용
const user = await userRepo.findOneBy({ socialSecurityNumber: '123-45-6789' });
// find()는 내부적으로 to()를 호출해 암호화된 값으로 검색
// ✅ 또는 QueryBuilder에서 수동 변환
const encrypted = new EncryptionTransformer().to('123-45-6789');
const user = await userRepo
.createQueryBuilder('user')
.where('user.socialSecurityNumber = :ssn', { ssn: encrypted })
.getOne();
NestJS 실무: 용도별 추천 컬럼 설계
| 데이터 유형 | 추천 컬럼 타입 | 이유 |
|---|---|---|
| 상태값 (status, role) | 문자열 Enum 또는 varchar | 디버깅 용이, DB 직접 조회 가능 |
| 민감 정보 (SSN, 카드번호) | varchar + EncryptionTransformer | 저장 시 암호화 자동 적용 |
| 사용자 설정/옵션 | simple-json (소량) 또는 jsonb | 유연한 스키마, 검색 필요 시 jsonb |
| 금액 (BigInt) | bigint + BigIntTransformer | JS number 범위 초과 방지 |
| 태그/라벨 목록 | simple-array 또는 json | 간단한 배열 저장 |
| 날짜/시간 | timestamp/datetime (기본) | Transformer 불필요 (TypeORM 자동 처리) |
운영 체크리스트 5항목
- 모든 Transformer에 null/undefined 방어 코드 — nullable 컬럼과 optional 필드에서 null이 to()/from()에 전달됩니다.
- JSON 컬럼 수정 시 새 객체 생성 — 스프레드 연산자로 복사해야 Change Detection이 동작합니다.
- Enum은 문자열 기반 사용 — 숫자 enum은 MySQL ENUM의 문자열 저장과 타입 불일치를 유발합니다.
- Enum 값 추가는 목록 끝에만 — 중간 삽입/제거 시 ALTER TABLE이 테이블 잠금을 유발할 수 있습니다.
- QueryBuilder에서 Transformer 미적용 주의 — 암호화 등 Transformer 의존 컬럼은 find()를 쓰거나 수동 변환을 추가하세요.
정리
TypeORM의 컬럼 타입은 “DB와 코드 사이의 계약”입니다. ValueTransformer로 암호화·타입 변환을 자동화하고, Enum으로 상태값을 타입 안전하게 관리하며, JSON 컬럼으로 유연한 스키마를 구현할 수 있습니다.
핵심은 Transformer의 null 방어, JSON의 Change Detection 한계, Enum의 Migration 위험, 그리고 QueryBuilder에서 Transformer가 무시되는 동작을 미리 알고 대비하는 것입니다.
참고 자료: TypeORM 공식 — Column Types | TypeORM 공식 — Entity Columns