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 NULLname?: 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 — 이 멘탈모델로 실제 엔티티를 정의하는 모든 데코레이터를 한 줄씩 분해해 본다.