TypeORM 과 읽기 전용 엔티티
TypeORM 과 읽기 전용 엔티티
TypeORM 은 한때 Node 진영의 대표적 ORM 이었고 NestJS 와 함께 널리 쓰였습니다. 새 ORM 들이 등장하면서 위치가 다소 바뀌었지만 여전히 많은 코드베이스에서 운영됩니다.
1. TypeORM 에 대한 이야기
TypeORM 은 2016 년경 첫 공개된 TypeScript/JavaScript ORM 으로 알려져 있습니다. PostgreSQL · MySQL · MariaDB · SQLite · MS SQL Server · Oracle 등 다양한 RDB 와 일부 NoSQL (MongoDB) 을 지원합니다.
| 라이브러리 | 첫 등장 | 성격 |
|---|---|---|
| TypeORM | 2016 | 데코레이터 기반. AR · DM 양쪽 지원. |
| Sequelize | 2010 | 가장 오래된 Node ORM. JS 우선. |
| MikroORM | 2018 | TS 우선. Identity Map · Unit of Work. |
| Prisma | 2019 (Prisma 1) → 2020 (현재 Prisma) | 스키마 파일 + 생성된 클라이언트. |
| Drizzle ORM | 2022 | TS 스키마 코드. SQL 에 가까운 빌더. |
| Kysely | 2022 | type-safe 쿼리 빌더 (엄밀히는 ORM 아님). |
2. Active Record vs Data Mapper
Martin Fowler 의 Patterns of Enterprise Application Architecture (2002) 가 정리한 두 패턴입니다.
- Active Record — 엔티티 객체가 자기 자신을 저장합니다.
user.save(). - Data Mapper — 별도 매퍼·리포지토리가 엔티티를 영속화합니다.
repo.save(user).
TypeORM 은 둘 다 제공합니다.
// Active Record
@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn() id!: number
@Column() email!: string
}
const u = User.create({ email: 'a@b' })
await u.save()
// Data Mapper
const repo = dataSource.getRepository(User)
await repo.save(repo.create({ email: 'a@b' }))
서비스 계층 분리·테스트 용이성이 강조되는 환경에서는 Data Mapper 쪽이 자주 선택됩니다.
3. 엔티티 → 읽기 전용 SSOT 패턴
TypeORM 은 엔티티 메타데이터로 스키마를 동기화 (synchronize: true) 하거나 마이그레이션을 생성 (migration:generate) 할 수 있습니다. 운영에서는 두 가지 방향이 흔히 갈립니다.
① TypeORM 이 스키마의 진실 — 엔티티가 SSOT, 마이그레이션은 자동 생성
② SQL 이 스키마의 진실 — 엔티티는 매핑만, 스키마 변경은 SQL 파일에서
(2) 의 경우 엔티티 데코레이터는 컬럼 이름·타입·관계만 표현하고 synchronize: true 는 절대 켜지 않습니다. 운영 DB 의 변경은 별도 SQL 또는 마이그레이션 도구로만 합니다. 장점:
- DBA · 데이터팀이 SQL 을 직접 다룰 수 있습니다.
- 자동 생성 마이그레이션의 위험한 변경 (컬럼 삭제·타입 변경) 을 막습니다.
- 같은 DB 를 다른 언어/서비스가 공유할 때 진실 출처가 한 곳입니다.
단점은 엔티티와 실제 DB 가 어긋날 수 있다는 점입니다. 통합 테스트로 양쪽 일치를 검증하는 절차가 필요합니다.
4. 관계와 N+1
@Entity()
class Order {
@ManyToOne(() => User) user!: User
}
const orders = await repo.find()
for (const o of orders) {
console.log(o.user.email) // lazy load: 매번 SELECT
}
이 패턴이 전형적인 N+1 문제입니다. 한 번의 메인 쿼리 + N 번의 보조 쿼리. 해결은 명시적 join.
const orders = await repo.find({ relations: { user: true } })
// 또는 query builder
const orders = await repo.createQueryBuilder('o')
.leftJoinAndSelect('o.user', 'u')
.getMany()
또는 DataLoader 같은 배치 로더 패턴을 도입합니다 (GraphQL 환경에서 흔함).
5. Prisma · Drizzle · MikroORM 과의 비교
Prisma — schema.prisma 가 SSOT, 클라이언트가 코드 생성. 타입 추론이 강하고 마이그레이션이 잘 정리돼 있습니다. 런타임은 쿼리 엔진(Rust 바이너리) 을 거치는 구조였고 최근에는 엔진을 줄이는 방향이 진행 중입니다.
Drizzle — SQL 에 가까운 빌더. 스키마는 TS 코드. 런타임 오버헤드가 작고 타입 추론이 강합니다.
MikroORM — Identity Map · Unit of Work 같은 엔터프라이즈 패턴을 정확히 구현합니다. 엔티티 변경을 추적해 한 번에 flush.
Sequelize — 오래된 만큼 안정적이지만 타입 추론은 위 셋에 비해 약하다는 평이 있습니다.
선택은 팀의 SQL 친밀도, 마이그레이션 방식, 타입 안정성의 필요 수준에 달려 있습니다.
6. DataSource 와 Repository
DataSource 는 연결 풀 + 메타데이터를 담는 객체입니다. 앱 시작 시 한 번 초기화하고 전역에서 참조합니다.
export const ds = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Order],
migrations: ['migrations/*.ts'],
synchronize: false, // 운영에서는 false
})
await ds.initialize()
const userRepo = ds.getRepository(User)
class UserService {
constructor(private repo = userRepo) {}
byEmail(email: string) { return this.repo.findOne({ where: { email } }) }
}
리포지토리 메서드를 서비스 안에 두는 형태와 별도 클래스로 빼는 형태가 모두 가능합니다.
7. 자주 걸리는 자리
synchronize: true 의 운영 적용 — 컬럼이 사라질 수 있습니다. 운영에서는 절대 끕니다.
@JoinColumn 누락 — ManyToOne 관계에서 외래키 컬럼명이 의도와 달라집니다. 명시 권장입니다.
순환 임포트 — 엔티티 간 양방향 관계는 함수형 참조 (() => Other) 로 lazy 로 두면 순환을 회피합니다.
타임존 컬럼 — PostgreSQL 의 timestamptz 와 timestamp 의 차이를 엔티티 데코레이터에 정확히 반영합니다. 잘못 매핑하면 시간이 어긋납니다.
트랜잭션 경계 — 여러 리포지토리를 한 트랜잭션으로 묶으려면 dataSource.transaction(async (manager) => ...) 안에서 매니저 기반 리포지토리를 써야 합니다.
하고픈 말
ORM 은 추상의 가치와 raw SQL 의 명료함 사이의 트레이드오프입니다. 작은 팀은 raw SQL 로 시작하는 게 운영 부담이 작고, 큰 팀이나 도메인이 많아지면 ORM 의 추상 이득이 보입니다. "엔티티는 매핑만, 스키마는 SQL" 패턴이 둘의 절충점에 가깝습니다.
Next
- crawler-ethics
- openapi-spec
TypeORM 공식 · TypeORM GitHub · Patterns of Enterprise Application Architecture · Prisma 공식 · Drizzle ORM 공식 · MikroORM 공식 · DataLoader 를 참고합니다.