05 — Mental Model
이 문서가 답하는 질문: TypeORM의 모든 동작을 한 줄의 멘탈모델로 어떻게 잡을 수 있는가? 다른 ORM과 비교했을 때 마음속에 그리는 그림은 무엇이 달라야 하는가? 한 줄 답 (Pyramid Top): “TypeORM의 핵심 멘탈모델은 ‘클래스 = 테이블 = 메타데이터 묶음’ — 한 클래스가 컴파일 타임 타입과 런타임 메타라는 이중 신원을 동시에 가지고, 그 둘이 Single Source of Truth로 일치하는 데서 모든 매력과 한계가 출발한다.”
한 문장 답 (Pyramid Top)
TypeORM을 한 그림으로 잡으려면 클래스의 이중 신원을 봐야 한다 — ① 컴파일 타임 신원: TypeScript 타입 시스템 안의 interface 같은 것, ② 런타임 신원:
MetadataArgsStorage에 박힌 메타 객체들의 묶음. 같은 클래스 선언이 두 자리에 동시에 존재하고, 그 두 자리를 일치시키는 것이 TypeORM의 사상이다. Prisma는 스키마 → 코드젠으로 두 자리를 분리한 채 동기화하고, Drizzle은 SQL DSL이 곧 타입이라 두 자리가 합쳐져 있다. TypeORM은 클래스 한 자리에 둘을 겹친다.
챕터 지도 (Mermaid)
한 자리에 두 신원이 겹쳐 있다 — 이게 TypeORM의 마법이자 한계.
Why — 왜 이 멘탈모델인가
ORM을 멘탈모델 없이 쓰면 모든 동작이 마법으로 보인다. 마법이 깨지는 순간(빌드 에러, 잘못된 SQL, N+1) 어디서 손을 대야 할지 모른다. 각 ORM은 다른 그림을 요구한다.
| ORM | 멘탈모델 한 줄 | 진실의 위치 |
|---|---|---|
| TypeORM | ”클래스가 곧 테이블이고 곧 메타다” | 클래스 |
| Prisma | ”스키마가 진실, 코드는 산출물” | schema.prisma |
| Drizzle | ”TS 스키마 = SQL 모양” | TS 파일 |
| MikroORM | ”EntityManager 안에 살아 있는 객체들이 떠 있다” | 클래스 + UoW |
| Sequelize | ”Model.init이 정의, instance가 사용” | Model 정의 |
| Kysely | ”쿼리는 그냥 SQL, 타입은 인터페이스에서” | 인터페이스 |
같은 도메인을 다루는데 마음속 그림이 이렇게 다르다는 게 비교의 본질이다.
How — 멘탈모델을 어떻게 만들 것인가
1) 클래스의 두 얼굴
같은 class User { @Column() name: string } 한 줄을 두 가지 시선으로 본다.
@Entity()
class User {
@PrimaryGeneratedColumn() id!: number;
@Column({ length: 50 }) name!: string;
@ManyToOne(() => Team) team!: Team;
}시선 1 — TypeScript의 눈으로:
// 컴파일 타임엔 사실상 이런 interface
interface User {
id: number;
name: string;
team: Team;
}이 신원이 IDE 자동완성, Repository<User>의 제네릭, find 결과의 타입을 만든다.
시선 2 — TypeORM 런타임의 눈으로:
// MetadataArgsStorage에 박힌 줄들
MetadataArgsStorage = {
tables: [{ target: User, name: "user" }],
columns: [
{ target: User, propertyName: "id", mode: "generated" },
{ target: User, propertyName: "name", options: { length: 50 } },
],
relations: [
{ target: User, propertyName: "team", type: () => Team, relationType: "many-to-one" }
],
}이 신원이 SQL 생성·마이그레이션·관계 그래프를 만든다.
같은 코드 한 줄이 두 개의 분리된 자료구조를 만든다 — 이게 TypeORM의 Single Source of Truth 표현이다.
2) DataSource — 이중 신원이 만나는 곳
런타임 신원(MetadataArgsStorage)은 전역 큐일 뿐이고, 의미를 갖는 것은 DataSource.initialize() 시점이다.
DataSource는 이중 신원이 결혼하는 자리다 — 데코레이터가 쌓아둔 메타와 연결 풀이 같이 들어가야 비로소 SQL이 만들어진다.
3) Repository — 도메인과 영속성의 경계
Repository<T>는 제네릭이 컴파일 타임 신원을 잇고, 내부 EntityMetadata가 런타임 신원을 잇는 게이트웨이다.
const repo: Repository<User> = ds.getRepository(User);
// ↑ ↑
// 컴파일 타임 타입 런타임 신원
// (find 결과 타입) (SQL 생성용 메타)하나의 객체가 두 신원을 동시에 들고 있다. 그래서 repo.find() 한 줄이 타입 안전한 결과와 정확한 SQL을 동시에 보장하는 듯 보인다.
4) DataMapper의 사상적 위치
TypeORM이 DataMapper를 권장하는 건 단순한 패턴 선호가 아니다. 이중 신원의 분리를 코드에까지 끌고 가려는 결정이다.
// DataMapper — 두 신원을 분리한 채 유지
class User { // 도메인 객체: 컴파일 타임 신원 + 행동
fullName() { return this.firstName + " " + this.lastName }
}
// 영속성은 외부에서: 런타임 신원을 다루는 Repository
await ds.getRepository(User).save(user);// ActiveRecord — 두 신원을 한 객체 안에 합침
class User extends BaseEntity { // 도메인 + 영속성 함께
fullName() { return this.firstName + " " + this.lastName }
// user.save()로 자기를 저장
}
await user.save();DataMapper에서는 도메인 객체가 자기 메타를 모른다 — user.save() 같은 메서드가 없다. 영속성 관련 행동은 Repository에만 있다. 이 분리가 큰 코드베이스의 테스트 격리와 책임 분할을 살려둔다.
5) 다른 ORM과의 그림 차이
- TypeORM: 한 자리(클래스)에서 두 가지가 동시에 나온다.
- Prisma: 한 자리(스키마)에서 두 산출물이 따로 나온다 (코드젠).
- Drizzle: 한 자리(TS 파일)에 두 가지가 합쳐져 있다.
세 그림 모두 Single Source of Truth를 추구하지만 위치와 산출 방식이 다르다.
What — 멘탈모델로 본 구체 동작
repo.find() 한 줄의 분해
const users = await ds.getRepository(User).find({
where: { active: true },
relations: { posts: true },
take: 10,
});| 단계 | 어느 신원이 일하는가 |
|---|---|
getRepository(User) | 런타임 신원: User의 EntityMetadata 찾기 |
.find({ ... }) 옵션 타입 검사 | 컴파일 타임 신원: User 타입과 옵션 비교 |
| where 조건 → SQL WHERE | 런타임 신원: 컬럼 메타로 변환 |
| relations: posts → JOIN | 런타임 신원: 관계 메타로 JOIN SQL |
| 결과 row → User 인스턴스 | 런타임 신원: 메타로 매핑 |
반환 타입 Promise<User[]> | 컴파일 타임 신원 |
같은 한 줄에 두 신원이 번갈아 일한다.
Repository가 가진 것 (멘탈모델 관점)
class Repository<Entity> {
// 1. 컴파일 타임: Entity 타입을 제네릭으로 보존
// 2. 런타임: target 클래스의 EntityMetadata 보유
metadata: EntityMetadata;
// 3. 연결: DataSource의 QueryRunner 접근
manager: EntityManager;
// 메서드들은 (1)+(2)+(3)을 조합해 동작
}세 가지가 동시에 들어 있는 객체가 Repository다.
이중 신원이 어긋날 때
이론적으로 두 신원은 같은 클래스에서 파생되니 항상 일치해야 한다. 그러나 어긋날 수 있는 자리가 있다.
| 어긋남 | 원인 | 결과 |
|---|---|---|
타입은 string인데 DB는 int | @Column({ type: "int" })를 잘못 줌 | 런타임 SQL 실패 |
타입은 User인데 메타는 미정의 | () => User 누락 | ”Cannot read ‘name‘“ |
| 컴파일 타임엔 optional, DB는 NOT NULL | name?: string + @Column() (nullable 미설정) | INSERT 시 에러 |
| Entity는 변경했는데 DB는 미반영 | 마이그레이션 미실행 | 컬럼 없음 에러 |
*“이중 신원의 일치”*가 깨지면 그 자리에서 정확히 한 가지 에러가 난다 — 그래서 멘탈모델이 잡혀 있으면 디버깅이 빠르다.
What-if — 멘탈모델 없이 쓰면
1) 클래스를 단순히 ‘TypeScript 타입’으로만 보면
→ @Column 옵션을 빼고 string만 적으면 알아서 될 것이라 기대 → design:type이 못 잡는 타입(union, generic)에서 실패.
대응: 매번 *“이 한 줄이 메타 저장소에도 박히는가?”*를 떠올린다.
2) DataMapper와 ActiveRecord를 같은 것으로 보면
→ user.save()로 시작했다가 도메인 객체가 DB를 알아버려 테스트가 깨진다.
대응: *“도메인 객체가 영속성을 알아야 하는가?”*가 멘탈모델 안에 있어야 함.
3) DataSource를 ‘그냥 connection’으로만 보면
→ DataSource가 연결 + 메타 그래프 + Repository 팩토리인 줄 모르고 매 요청마다 새로 만들거나 전역 캐시를 잘못 만들어 누수 발생. 대응: DataSource = 연결의 인생 전체 한 그림으로 잡기.
4) Prisma와 같은 그림으로 TypeORM을 쓰면
→ “schema.prisma 같은 진실의 파일이 있겠지” 찾다가 클래스 자체가 진실임을 못 받아들임. migration:generate의 한계를 버그라 부른다.
대응: 진실의 위치를 클래스에 둔다는 결정을 받아들인다.
5) 런타임 메타를 디버깅 못 하면
→ “왜 이 컬럼이 SQL에 안 나오지?” 같은 상황에서 손을 댈 곳을 모른다.
대응: getMetadataArgsStorage() 또는 dataSource.entityMetadatas를 직접 console.log해본다 — 멘탈모델의 런타임 신원을 눈으로 확인.
Insight — 흥미로운 이야기
”데코레이터 ORM의 사상은 사실 Java Hibernate에서 왔다”
Java Hibernate(2001~)는 XML 매핑 파일로 시작했다. Java가 2004년 어노테이션을 도입하자 Hibernate는 @Entity @Column 어노테이션으로 옮겨갔다 — 이게 “클래스 한 자리에 메타를 박는다” 사상의 원조다. 2016년 TypeORM이 한 일은 그 사상을 TypeScript로 옮긴 것이고, NestJS가 그것을 백엔드 아키텍처의 기본으로 만들었다. 즉 TypeORM의 멘탈모델은 2004년 Hibernate의 그림과 본질적으로 같다. 그 그림이 22년이 지난 지금도 유효한가가 — Prisma·Drizzle이 제기하는 진짜 질문이다.
”Single Source of Truth의 세 가지 해석”
- TypeORM: 진실은 클래스에 있다. 컴파일 타임/런타임이 같은 자리에서 나온다.
- Prisma: 진실은 별도 파일에 있다. 코드는 그 파일의 산출물이다.
- Drizzle: 진실은 TS 코드 자체고, SQL 모양이 곧 타입이다.
같은 ‘SSOT’라는 표현이 세 가지 다른 그림을 만든다. 어느 그림이 내 팀의 사고방식에 맞는지가 ORM 선택의 본질이다.
”왜 Repository는 ‘얇은 게이트웨이’인가”
TypeORM의 Repository<T>는 논리가 거의 없다 — 옵션을 받아 EntityMetadata와 결합해 SQL을 만드는 게 전부다. 이 얇음은 의도된 설계다. 두꺼운 메서드(findActiveUsersWithRecentPosts() 같은)는 사용자 코드의 CustomRepository나 서비스 레이어로 가야 한다. Repository는 ‘데이터에 닿는 도구’이지 ‘비즈니스 로직의 자리’가 아니다 — DataMapper 사상이 코드 구조에까지 끌리는 자리.
요약 + 다이어그램
TypeORM의 멘탈모델은 ‘클래스 = 테이블 = 메타데이터 묶음’ 한 줄로 압축된다. 한 클래스가 컴파일 타임 타입과 런타임 메타라는 이중 신원을 동시에 가지고, DataSource가 그 두 신원을 결혼시킨다. Repository는 두 신원을 들고 있는 얇은 게이트웨이고, DataMapper는 그 분리를 코드 구조에까지 끌고 가는 사상이다.
다음 챕터:
01-entity-decorators— 이 멘탈모델로 실제 엔티티를 정의하는 모든 데코레이터를 한 줄씩 분해해 본다.