02 · @Column 타입

이 문서가 답하는 질문: @Column()어디까지 자동 추론하고, type/nullable/default/length/unique/comment각각 무엇을 결정하는가? 한 줄 답 (Pyramid Top): @Column 타입 추론은 TypeScript 원시 타입(string·number·boolean)만 동작한다 — Date·enum·decimal·json명시하지 않으면 잘못된 컬럼이 만들어진다.”


Why — 추론은 왜 일부만 동작하나

TypeORM은 컴파일 타임 타입을 emitDecoratorMetadata런타임에 읽는다. 그러나 TypeScript가 emit 하는 런타임 타입 토큰은 5개뿐이다:

  • String
  • Number
  • Boolean
  • Object (interface, type alias 등 모든 복합)
  • Function (단순 호출 가능)
  • (Date만 예외적으로 클래스라 직접 잡힘)

enum·Date·custom class·union type은 전부 Object로 보인다. 이게 추론의 한계선이다.

@Column() name: string       // ✅ String → varchar (자동)
@Column() age: number        // ✅ Number → integer (자동)
@Column() active: boolean    // ✅ Boolean → boolean (자동)
@Column() bornAt: Date       // ⚠️  Date → datetime (동작은 함, but 정밀도/타임존 미정)
@Column() role: Role         // ❌  Object → 컬럼 타입 오류
@Column() price: number      // ❌  Number → integer (decimal로 만들고 싶었다면 명시 필요)

핵심: string/number/boolean 외에는 항상 type을 명시하라. 이것이 첫 번째 안전 규칙이다.


How — @Column 옵션 6축

1) type — DB 컬럼 타입

@Column({ type: 'varchar', length: 100 }) name!: string
@Column({ type: 'text' }) bio!: string
@Column({ type: 'int' }) age!: number
@Column({ type: 'bigint' }) views!: string   // ⚠️ string으로 받는다 (JS number 정밀도 한계)
@Column({ type: 'decimal', precision: 12, scale: 2 }) price!: string  // ⚠️ string
@Column({ type: 'boolean' }) active!: boolean
@Column({ type: 'timestamp with time zone' }) updatedAt!: Date
@Column({ type: 'jsonb' }) metadata!: Record<string, unknown>
@Column({ type: 'uuid' }) externalId!: string
@Column({ type: 'enum', enum: ['ADMIN', 'USER'] }) role!: 'ADMIN' | 'USER'
@Column({ type: 'simple-array' }) tags!: string[]    // CSV로 저장
@Column({ type: 'simple-json' }) settings!: any      // JSON 문자열로 저장 (DB가 json 미지원일 때)

드라이버별 차이:

TS 타입PGMySQLSQLiteSQL Server
string 기본varcharvarchar(255)varcharnvarchar
number 기본integerintintegerint
Date 기본timestampdatetimedatetimedatetime
boolean 기본booleantinyint(1)booleanbit
bigintbigintbigintbigintbigint

2) nullable

@Column({ nullable: true }) bio?: string

기본은 nullable: falsePG의 NOT NULL이 기본. TS는 그러나 string 그대로 두면 컴파일 타입null 아님이라 불일치가 생긴다. 정직하게:

@Column({ nullable: true })
bio!: string | null     // 또는 ?: string

3) default

@Column({ default: 0 }) score!: number
@Column({ default: false }) active!: boolean
@Column({ default: 'PENDING' }) status!: string
 
// SQL 함수 — 따옴표 처리 주의
@Column({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date
@Column({ type: 'uuid', default: () => 'gen_random_uuid()' }) id!: string

원칙: 상수는 그대로, DB 함수함수 리터럴로 감싼다. 함수로 감싸지 않으면 문자열로 박혀 'CURRENT_TIMESTAMP'라는 이 default가 되는 사고가 난다.

4) length / precision / scale

@Column({ type: 'varchar', length: 100 }) title!: string
@Column({ type: 'decimal', precision: 12, scale: 2 }) amount!: string
@Column({ type: 'numeric', precision: 5, scale: 0 }) percent!: string
옵션적용 타입의미
lengthvarchar/char최대 문자 수
precisiondecimal/numeric총 자릿수
scaledecimal/numeric소수점 이하
widthint (MySQL)display width (의미 없음, MySQL 8.0+에서 deprecated)

5) unique

@Column({ unique: true }) email!: string

DDL: email varchar UNIQUE. 그러나 복합 unique@Column으로 표현 못 한다 — @Index 또는 @Unique클래스 레벨에 붙인다:

import { Entity, Unique } from 'typeorm'
 
@Entity()
@Unique(['email', 'tenantId'])
class User {
  @Column() email!: string
  @Column() tenantId!: string
}

6) name / comment / select

@Column({
  name: 'user_email',        // DB 컬럼명 (snake_case)
  comment: '인증용 이메일',   // DDL의 COMMENT
  select: false,             // find()에 기본 미포함 (password 등)
})
email!: string

select: false비밀번호 해시처럼 기본 SELECT에서 제외하고 싶은 컬럼에 쓴다. 명시적으로 select: ['password'] 하면 다시 들어온다.


What — 자동 추론 vs 명시 매트릭스

자동 추론되는 경우

@Column() name: string         // → varchar(255)
@Column() age: number          // → int
@Column() active: boolean      // → boolean
@Column() bornAt: Date         // → timestamp (PG) / datetime (MySQL)

명시해야 동작하는 경우

// enum
@Column({ type: 'enum', enum: Role })
role!: Role
 
// decimal — number로 두면 int가 됨
@Column({ type: 'decimal', precision: 10, scale: 2 })
price!: string                    // ⚠️ string으로 받음
 
// bigint — JS number는 53비트 한계
@Column({ type: 'bigint' })
views!: string                    // ⚠️ string으로 받음
 
// json
@Column({ type: 'jsonb' })
metadata!: Record<string, unknown>
 
// array
@Column('text', { array: true })
tags!: string[]                   // PG only
 
// simple-array (CSV)
@Column('simple-array')
tags!: string[]                   // 모든 DB

bigint·decimal왜 string인가

JavaScript의 number는 IEEE 754 double로 2^53 = 9,007,199,254,740,992까지만 정확하다. DB의 bigint(2^63)·decimal(18,4)는 그 한계를 넘는다. 그래서 TypeORM은 기본적으로 string으로 매핑한다 — 정밀도를 유지하기 위해.

TypeScript 측 우회:

@Column({ type: 'bigint', transformer: { to: (n: number) => n, from: (s: string) => Number(s) } })
views!: number                    // 의식적으로 number로 변환 — 53비트 안전 범위에서만

transformerDB ↔ TS 변환을 끼우는 출구다.

enum — DB별 차이

enum Role { ADMIN = 'ADMIN', USER = 'USER' }
 
@Column({ type: 'enum', enum: Role, default: Role.USER })
role!: Role
DB실제 DDL
PostgreSQLCREATE TYPE role_enum AS ENUM ('ADMIN', 'USER') + 컬럼
MySQLENUM('ADMIN', 'USER') 컬럼
SQLiteenum 없음varchar로 떨어짐
SQL Serverenum 없음nvarchar + CHECK constraint

주의: PG에서 enum 값 추가/삭제는 마이그레이션이 까다롭다 (ALTER TYPE ... ADD VALUE만 가능, 삭제 불가). 자주 바뀌는 도메인이면 varchar + CHECK가 더 유연하다.

전체 예제

import { Entity, PrimaryGeneratedColumn, Column, Unique } from 'typeorm'
 
enum UserRole { ADMIN = 'ADMIN', USER = 'USER' }
 
@Entity({ name: 'users' })
@Unique(['email', 'tenantId'])
class User {
  @PrimaryGeneratedColumn('uuid')
  id!: string
 
  @Column({ length: 100 })
  name!: string                              // varchar(100) NOT NULL
 
  @Column({ unique: true })
  email!: string                             // varchar UNIQUE
 
  @Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
  role!: UserRole                            // enum
 
  @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
  balance!: string                           // numeric(12,2)
 
  @Column({ type: 'jsonb', nullable: true })
  metadata!: Record<string, unknown> | null
 
  @Column({ select: false })
  passwordHash!: string                      // find()에 기본 미포함
 
  @Column({ name: 'tenant_id' })
  tenantId!: string
 
  @Column({ comment: '소프트 삭제 사유' })
  deleteReason?: string
}

What-if — 자주 틀리는 패턴

함정 1) price: number로 두기

@Column() price!: number      // ❌ int로 떨어진다 → 소수점 손실

해결: @Column({ type: 'decimal', precision: 12, scale: 2 }) price!: string. string으로 받는다. number 변환은 명시적으로 Number(row.price) 또는 transformer로.

함정 2) default: 'CURRENT_TIMESTAMP' (문자열)

@Column({ default: 'CURRENT_TIMESTAMP' })   // ❌ 문자열 'CURRENT_TIMESTAMP'가 default
createdAt!: Date

해결: 함수로 감싸기.

@Column({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date

함정 3) bigint인데 number로 받기

@Column({ type: 'bigint' })
views!: number                  // ⚠️ 큰 값이 들어오면 정밀도 손실

해결: string으로 받거나 transformer로 변환 범위를 의식하면서 number로.

함정 4) enum 변경 시 PG 마이그레이션 함정

// 이전
enum Role { ADMIN, USER }
 
// 변경
enum Role { ADMIN, USER, GUEST }   // GUEST 추가

PG에서 enum 값 추가ALTER TYPE role_enum ADD VALUE 'GUEST' 하나로 가능하지만, 순서 변경·삭제는 불가능 — 새 타입 만들어 컬럼 교체해야 한다. 자주 바뀌면 enum 대신 varchar + CHECK가 더 운영하기 쉽다.

함정 5) nullable: true인데 TS는 non-null

@Column({ nullable: true })
bio!: string                    // ⚠️ 런타임에 null이 올 수 있지만 TS는 모른다

해결: TS 타입을 정직하게 string | null로 두거나 ?:로.

@Column({ nullable: true })
bio!: string | null

함정 6) simple-array로 큰 데이터 저장

@Column('simple-array') tags!: string[]   // CSV로 직렬화

'a,b,c'로 저장된다 — 원소에 쉼표가 있으면 깨진다. 대용량이나 복잡한 구조는 'jsonb'/'json'을 써라.


Insight — 흥미로운 이야기

“TypeScript 데코레이터 메타는 원래 5개 타입만 알려준다”

emitDecoratorMetadata는 TC39 spec이 아니라 TypeScript의 임시 옵션이다 (2015년 도입, 여전히 stage 2). 이 옵션이 emit하는 design:type 메타데이터는 런타임 생성자 함수 참조인데 — String/Number/Boolean/Date/Array/Object/Function 같은 글로벌 클래스만 정확하게 잡힌다. 그 외(interface·union·literal type)는 전부 Object로 떨어진다 — TS 컴파일러가 *지움(erasure)*이기 때문이다. 이게 TypeORM이 enum·decimal·json명시 요구하는 진짜 이유다. Decorator Stage 3 spec은 이 메타데이터를 공식 표준에 포함시키지 않기로 했다 — TypeORM 같은 라이브러리의 운명어쩌면 다음 메이저에서 갈릴 수 있다.

bigintstring으로 받는 결정은 정직하지만 피로하다

Hibernate(Java)는 long언어 차원에서 64비트라 그냥 매핑된다. Prisma(TS)는 BigInt 글로벌을 쓴다. TypeORM은 문자열 결정을 택했다 — BigInt2020년에야 TS에 자리 잡았기 때문. 호환성은 안전했지만 모든 산술이 Number(s) 변환을 거쳐야 한다. 새 프로젝트라면 BigInt 트랜스포머를 기본 옵션으로 두는 것을 고려해라.


요약 + Mermaid

요약: @Column의 자동 추론은 *4 타입(string·number·boolean·Date)*만 동작한다. enum·decimal·bigint·json·array반드시 명시해야 한다. nullable: true면 TS 타입도 | null로 정직하게 적어라. select: false로 비밀번호 같은 컬럼을 기본 SELECT에서 제외하는 습관이 안전하다. 다음 문서(03-primary-keys)는 이 컬럼 중 무엇이 PK가 되는가를 다룬다.