🗄️ TypeORM1. Entity & 데코레이터04 · 특수 컬럼 (날짜·버전·소프트삭제)

04 · 특수 컬럼 — 날짜·버전·소프트삭제

이 문서가 답하는 질문: @CreateDateColumn/@UpdateDateColumn/@DeleteDateColumn/@VersionColumn 네 데코레이터는 각각 무엇을 자동화하고, 어떤 부작용을 가지는가? 한 줄 답 (Pyramid Top): “네 데코레이터는 시간·삭제·동시성을 한 줄로 켜는 편의 장치다 — 그러나 @DeleteDateColumn 한 줄이 find·save·관계 로딩의 의미를 전부 바꾼다.”


Why — 왜 시스템 컬럼에 별도 데코레이터를 두나

거의 모든 도메인 테이블이 공통으로 필요한 4개 컬럼이 있다:

  1. 생성 시각언제 만들어졌나
  2. 갱신 시각마지막으로 언제 바뀌었나
  3. 삭제 표시논리적으로 삭제됐나 (실제 DELETE 대신)
  4. 버전 번호동시 수정 충돌을 감지하는 카운터

이 넷을 *그냥 @Column()*으로 적어도 동작은 한다. 그러나 매번 INSERT/UPDATE 콜백을 적어야 한다. TypeORM은 4개 전용 데코레이터로 그 보일러플레이트를 프레임워크 레벨로 옮겼다.

@Column({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date            // ⚠️ INSERT 때만 자동, UPDATE는 별도 코드 필요
 
// vs
 
@CreateDateColumn()
createdAt!: Date            // ✅ INSERT 시 TypeORM이 자동 세팅

핵심: 이 데코레이터들은 컬럼 정의 + ORM 라이프사이클 훅을 묶은 2-in-1 장치다.


How — 네 데코레이터, 각자의 책임

1) @CreateDateColumn — 생성 시각

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'
 
@Entity()
class Article {
  @PrimaryGeneratedColumn('uuid') id!: string
  @Column() title!: string
 
  @CreateDateColumn()
  createdAt!: Date
}
DBDDL
PostgreSQLcreated_at timestamp NOT NULL DEFAULT now()
MySQLcreated_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
SQLitecreated_at datetime NOT NULL DEFAULT (datetime('now'))

INSERT 시 TypeORM이 직접 세팅하지 않고 DB의 default로 떨어진다 — 그래서 RETURNING을 지원하는 DB에선 insert 후 createdAt이 자동으로 채워져 돌아온다.

2) @UpdateDateColumn — 갱신 시각

@UpdateDateColumn()
updatedAt!: Date
DB동작
MySQLON UPDATE CURRENT_TIMESTAMP (DB가 자동)
그 외TypeORM의 save() 라이프사이클이 직접 새 Date를 세팅

중요한 차이:

// repo.save() — TypeORM 라이프사이클 거침
await repo.save({ id: 'X', title: 'new' })   // ✅ updatedAt 갱신됨
 
// queryBuilder.update() — TypeORM이 컬럼 자동 세팅 안 함
await repo.createQueryBuilder()
  .update().set({ title: 'new' })
  .where('id = :id', { id: 'X' })
  .execute()                                  // ❌ MySQL 외엔 updatedAt 안 바뀜

QueryBuilder로 직접 UPDATE할 때는 set({ title: 'new', updatedAt: new Date() })수동 세팅해야 한다. 또는 MySQL의 ON UPDATE에 의존한다.

3) @DeleteDateColumn — soft delete

import { DeleteDateColumn } from 'typeorm'
 
@Entity()
class Article {
  @PrimaryGeneratedColumn('uuid') id!: string
  @Column() title!: string
 
  @DeleteDateColumn()
  deletedAt?: Date | null      // 삭제 안 됐으면 null
}

DDL: deleted_at timestamp NULL (기본 null).

이 한 줄이 4개의 동작을 바꾼다:

// 1. delete()는 더 이상 SQL DELETE를 안 부른다
await repo.softDelete('X')         // UPDATE article SET deleted_at = now() WHERE id = 'X'
 
// 2. find()는 deleted_at IS NULL을 자동으로 붙인다
await repo.find()                  // SELECT ... WHERE deleted_at IS NULL
 
// 3. 삭제된 row도 보려면 명시
await repo.find({ withDeleted: true })
 
// 4. 복원
await repo.restore('X')            // UPDATE article SET deleted_at = NULL
 
// 진짜 DELETE를 하고 싶으면
await repo.delete('X')             // SQL DELETE (hard delete, soft delete 무시)

부작용 매트릭스:

메서드@DeleteDateColumn 없음@DeleteDateColumn 있음
repo.delete(id)SQL DELETESQL DELETE (강제 hard)
repo.softDelete(id)에러UPDATE deleted_at = now()
repo.restore(id)에러UPDATE deleted_at = NULL
repo.find()전부 가져옴deleted_at IS NULL만
repo.find({ withDeleted: true })같음삭제 포함 전부
qb.getMany()전부deleted_at IS NULL만 (자동)
qb.withDeleted().getMany()같음전부

관계 로딩에도 적용:

@Entity()
class User {
  @OneToMany(() => Article, a => a.author) articles!: Article[]
}
 
const user = await userRepo.findOne({
  where: { id: 'X' },
  relations: ['articles'],            // 삭제된 article 제외 (자동)
})

user.articlesdeleted_at NULL인 것만 들어온다 — 부모 쿼리에서 자동 join 조건이 박힘. 이 동작이 의도 안 한 경우엔 명시적 QueryBuilder로 풀어야 한다.

4) @VersionColumn — 낙관적 락

import { VersionColumn } from 'typeorm'
 
@Entity()
class Article {
  @PrimaryGeneratedColumn('uuid') id!: string
  @Column() title!: string
 
  @VersionColumn()
  version!: number                  // INSERT 시 1, UPDATE마다 +1
}

UPDATE SQL이 자동으로 변환된다:

-- TypeORM이 생성
UPDATE article SET title = ?, version = version + 1
WHERE id = ? AND version = ?    -- version 조건이 추가됨

이 SQL의 영향 받은 row 수가 0이면 낙관적 락 충돌 — TypeORM이 OptimisticLockVersionMismatchError를 던진다.

const a = await repo.findOneBy({ id: 'X' })    // version = 3
// ...
// 다른 트랜잭션이 같은 row를 update해서 version = 4가 됨
 
a.title = 'new'
await repo.save(a)                              // UPDATE ... WHERE version = 3 → 0 rows → 에러

언제 쓰나: 읽고-쓰기 사이 시간 간격이 길고 충돌 가능성이 있는 도메인 (장바구니·문서 편집·재고). 짧은 트랜잭션은 비관적 락(SELECT FOR UPDATE)이 단순.


What — 구체 사양과 옵션

@CreateDateColumn 옵션

@CreateDateColumn({
  type: 'timestamp with time zone',     // PG: timestamptz
  precision: 6,                         // microsecond
  name: 'created_at',
  comment: '생성 시각',
})
createdAt!: Date

기본 type은 DB별로 다르다:

  • PG: timestamp (timezone 없음 — 주의, 명시적으로 timestamp with time zone 권장)
  • MySQL: datetime(6)
  • SQLite: datetime

@UpdateDateColumn 옵션

@UpdateDateColumn({
  type: 'timestamp with time zone',
  precision: 6,
  name: 'updated_at',
})
updatedAt!: Date

MySQL의 ON UPDATE CURRENT_TIMESTAMP자동 추가된다 — 다른 DB는 TypeORM이 save 라이프사이클에서 세팅.

@DeleteDateColumn 옵션

@DeleteDateColumn({
  type: 'timestamp with time zone',
  precision: 6,
  name: 'deleted_at',
})
deletedAt?: Date | null

반드시 nullable이다 — 살아있는 row는 null이어야 한다.

@VersionColumn 옵션

@VersionColumn({
  name: 'version',
  default: 1,
})
version!: number

bigint로 받을 수도 있지만 대부분 4바이트 int로 충분하다 (2^31 = 약 21억 번 update).

전체 예제 — 4 데코레이터 동시 사용

import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn, DeleteDateColumn, VersionColumn,
} from 'typeorm'
 
@Entity()
class Document {
  @PrimaryGeneratedColumn('uuid')
  id!: string
 
  @Column() title!: string
  @Column({ type: 'text' }) body!: string
 
  @CreateDateColumn({ type: 'timestamp with time zone' })
  createdAt!: Date
 
  @UpdateDateColumn({ type: 'timestamp with time zone' })
  updatedAt!: Date
 
  @DeleteDateColumn({ type: 'timestamp with time zone' })
  deletedAt?: Date | null
 
  @VersionColumn()
  version!: number
}

이 6 컬럼이 대부분의 도메인 테이블의 골격이다.


What-if — 자주 틀리는 패턴

함정 1) soft delete를 켜고 INSERT 충돌에 놀라기

// 시나리오
await repo.softDelete('email_unique_X')      // deleted_at만 세팅
await repo.save({ email: 'X', ... })          // ❌ unique constraint violation

emailUNIQUE 제약이 있으면 삭제됐는데도 row가 살아있어 새 INSERT를 막는다. 해결:

// 1. partial unique index (PG)
@Index('idx_email_unique', ['email'], { unique: true, where: 'deleted_at IS NULL' })
 
// 2. unique를 (email, deletedAt) 복합으로
@Index(['email', 'deletedAt'], { unique: true })

함정 2) repo.delete()repo.softDelete() 혼동

await repo.delete('X')        // 항상 SQL DELETE — soft delete 무시
await repo.softDelete('X')    // soft delete (deletedAt 세팅)
await repo.remove(entity)     // soft delete *무시*하고 hard delete

무엇이 hard고 무엇이 soft인지 팀 컨벤션에 박아라. 보통 모든 비즈니스 코드는 softDelete, 관리자 도구만 delete.

함정 3) @UpdateDateColumn이 QueryBuilder UPDATE에 안 먹힘

// ❌ updatedAt이 안 바뀐다 (MySQL 외)
await repo.createQueryBuilder()
  .update().set({ title: 'new' })
  .where('id = :id', { id: 'X' })
  .execute()

해결: set({ title: 'new', updatedAt: () => 'CURRENT_TIMESTAMP' }) 명시.

함정 4) @VersionColumn을 다른 트랜잭션에서 변경

// 트랜잭션 A
const a = await repo.findOneBy({ id: 'X' })   // version=3
a.title = 'newA'
 
// 트랜잭션 B 동시 진행
const b = await repo.findOneBy({ id: 'X' })   // version=3
b.title = 'newB'
await repo.save(b)                             // version=4
 
// 트랜잭션 A 계속
await repo.save(a)                             // ❌ OptimisticLockVersionMismatchError

기대된 동작이다. UI에서 충돌 안내를 보여주고 최신 값을 다시 로드하게 만들어야 한다.

함정 5) createdAt직접 세팅

await repo.save({ id: 'X', title: 't', createdAt: new Date('2020-01-01') })

가능은 하지만 의도 불명확하다. 보통은 마이그레이션·시드 데이터 정도. 일반 코드는 그냥 비워두고 default에 맡겨라.

함정 6) timezone 없는 timestamp로 글로벌 서비스 운영

@CreateDateColumn() createdAt!: Date     // PG default: timestamp (no tz!)

PG의 timestamptimezone 없음 — 서버 timezone에 따라 해석이 다르다. 글로벌 서비스라면 반드시 timestamptz:

@CreateDateColumn({ type: 'timestamp with time zone' })
createdAt!: Date

Insight — 흥미로운 이야기

“soft delete는 2010년대 SaaS의 자기방어 본능이었다”

클라우드 시대 이전, DB는 물리적 백업이 비싸고 복구가 느렸다. 사용자가 실수로 삭제한 데이터복구하는 능력제품 신뢰의 핵심이 됐다. Stripe·Slack·GitHub 모두 기본 soft delete를 채택했다. 그러나 *GDPR(2018)*이 *“잊혀질 권리”*를 강제하면서 — soft delete의 역설이 생겼다. 사용자가 법적으로 삭제 요청하면 진짜로 지워야 한다. 결과: 현대 시스템은 2단계 삭제를 구현한다 — soft delete → 30일 보존 → hard delete. @DeleteDateColumn그 중간 단계만 자동화한다.

@VersionColumnHibernate에서 유래한 데이터 정직성 장치다”

낙관적 락(Optimistic Locking)은 1976년 Kung & Robinson의 논문 *“On Optimistic Methods for Concurrency Control”*에서 출발했다. 비관적 락은 읽는 순간 잠그는 보수적 전략, 낙관적 락은 쓰는 순간 검증하는 신뢰 전략. 웹 시대 — HTTP 요청 간에 락을 들고 있는 게 불가능해지면서 — 낙관적 락이 사실상 표준이 됐다. Hibernate(2001)의 @Version이 가장 영향력 있는 구현이고, TypeORM의 @VersionColumn은 그것의 TS 포팅이다. 한 줄로 데이터 정직성을 켤 수 있는 시대는 지난 50년의 결과물이다.


요약 + Mermaid

요약: 네 데코레이터는 생성·갱신·삭제·동시성을 한 줄로 자동화한다. @DeleteDateColumn그 한 줄로 find·save·관계 로딩의 의미가 전부 바뀐다withDeletedpartial unique index를 의식해야 한다. @UpdateDateColumnQueryBuilder UPDATE에선 수동 세팅 필요. @VersionColumn읽고-쓰기 간격이 큰 도메인에 가치 있다. 다음 문서(05-embedded-and-inheritance)는 여러 엔티티가 같은 컬럼을 공유하는 패턴을 다룬다.