06 — ActiveRecord 모드
질문:
user.save()처럼 짧게 쓰는 ActiveRecord 모드는 언제 쓸 만한가? 왜 TypeORM은 기본을 DataMapper로 했는가? 한 줄 답:BaseEntity상속 모드는 스크립트·프로토타이핑·1-DataSource 앱에 매력적이다. 하지만 테스트·다중 DB·NestJS 통합에 약하다 — 그래서 DataMapper가 기본이다.
Why — 왜 그래도 ActiveRecord 모드가 있는가
TypeORM의 공식 권장은 DataMapper다. 그런데 ActiveRecord 모드를 왜 유지하는가?
답은 진입 장벽이다.
// DataMapper — 4줄
const dataSource = await new DataSource({ ... }).initialize();
const userRepo = dataSource.getRepository(User);
const user = await userRepo.findOneBy({ id: 1 });
await userRepo.save(user);
// ActiveRecord — 2줄
const dataSource = await new DataSource({ ... }).initialize();
const user = await User.findOneBy({ id: 1 }); // ← User에 정적 메서드
await user.save(); // ← 인스턴스 메서드→ 프로토타이핑·CLI 스크립트·튜토리얼에서 코드를 짧게 보이고 싶다면 ActiveRecord가 이긴다. Rails·Django와 비슷한 멘탈 모델이 익숙한 사람에게도.
How — BaseEntity 상속
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm';
@Entity()
export class User extends BaseEntity { // ★ 핵심
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
email: string;
}BaseEntity를 상속하면 정적 + 인스턴스 메서드가 같이 들어온다.
정적 메서드 (클래스 단위)
await User.find({ where: { active: true } });
await User.findOne({ where: { id: 1 } });
await User.findOneBy({ id: 1 });
await User.findOneOrFail({ where: { id: 1 } });
await User.findAndCount({ ... });
await User.count({ ... });
await User.insert({ name: 'Alice' });
await User.update({ id: 1 }, { name: 'Bob' });
await User.delete(1);
await User.create({ name: 'Charlie' }); // ← new User() + assign
await User.save({ name: 'Dave' });→ Repository의 메서드를 그대로 정적 메서드로 노출한다. 내부적으로 BaseEntity.getRepository()를 호출한다.
인스턴스 메서드 (객체 단위)
const user = new User();
user.name = 'Alice';
await user.save(); // ← INSERT 또는 UPDATE
await user.reload(); // ← DB에서 다시 로드
await user.remove(); // ← DELETE
await user.softRemove(); // ← soft DELETE
await user.recover(); // ← restoreuseDataSource — 정적 DataSource 바인딩
BaseEntity는 전역 DataSource를 참조한다. 이를 명시적으로 설정해야 한다.
// 앱 부트스트랩
const dataSource = await new DataSource({ ... }).initialize();
BaseEntity.useDataSource(dataSource); // ★ 전역 바인딩
// 이제 어디서든:
await User.find(); // dataSource를 *간접 참조*→ 이 한 줄을 깜빡하면 모든 User.find()가 런타임에 에러. 이게 ActiveRecord의 첫 함정이다.
What — 짧음의 대가 매트릭스
| 기준 | ActiveRecord | DataMapper |
|---|---|---|
| 코드 길이 | 짧다 | 길다 |
| 학습 곡선 | 완만 | 가파름 |
| DataSource 의존 | 전역 (BaseEntity.useDataSource) | 명시적 주입 |
| 다중 DataSource | useDataSource 호출 매번 필요 | 자연스러움 |
| 단위 테스트 | DB mocking 어려움 | Repository mock 쉬움 |
| NestJS 통합 | 불편 — DI 우회 | 완벽 통합 |
| 도메인 메서드 위치 | 엔티티 클래스 안 (혼합) | Custom Repository (분리) |
| Transaction 안에서 동작 | 까다로움 | manager.withRepository |
| DDD 친화도 | 낮음 (엔티티 ⊥ POJO 아님) | 높음 |
What-if — 언제 써도 되는가
ActiveRecord 모드가 합리적인 경우.
1) CLI 스크립트·일회성 마이그레이션
// scripts/import-users.ts
import { DataSource, BaseEntity } from 'typeorm';
import { User } from '../src/user.entity';
(async () => {
const ds = await new DataSource({ ... }).initialize();
BaseEntity.useDataSource(ds);
const csv = fs.readFileSync('users.csv', 'utf-8');
for (const line of csv.split('\n')) {
const [name, email] = line.split(',');
await User.save({ name, email });
}
})();→ 짧고 명료하다. DI 컨테이너가 필요 없는 스크립트라면 ActiveRecord가 더 자연스럽다.
2) 튜토리얼·강의·블로그 코드
학습용으로 Repository 주입·DataSource 구성을 덜 보여주고 본질에만 집중하고 싶을 때.
3) Express + 1 DB의 단순 CRUD
NestJS·DDD 같은 프레임워크의 의례가 없는 작은 앱.
app.get('/users/:id', async (req, res) => {
const user = await User.findOneBy({ id: +req.params.id });
res.json(user);
});What-if — 언제 쓰지 말아야 하는가
1) NestJS 프로젝트
NestJS의 DI 컨테이너가 Repository를 주입하는데, BaseEntity.useDataSource는 DI를 우회한다. 두 패턴이 충돌한다.
// NestJS 컨트롤러에서 ActiveRecord 호출
@Controller('users')
class UserController {
@Get(':id')
async findOne(@Param('id') id: number) {
return User.findOneBy({ id });
// ↑ DI 컨테이너 밖. 어떤 DataSource를 쓰는지 *추적 불가*
}
}→ NestJS를 쓴다면 무조건 DataMapper.
2) 다중 DataSource (multi-tenant, sharding)
// 테넌트 A
await BaseEntity.useDataSource(dsA);
const userA = await User.findOneBy({ id: 1 });
// 테넌트 B로 전환
await BaseEntity.useDataSource(dsB); // ← 전역 변경!
const userB = await User.findOneBy({ id: 1 });
// 동시 요청이 들어오면 *서로 다른 테넌트의 데이터*가 섞일 수 있다→ BaseEntity.useDataSource는 static 변수다. 동시성 환경에서 race condition 위험. 다중 DataSource는 반드시 DataMapper.
3) 단위 테스트가 중요한 프로젝트
// 테스트
import { User } from '../src/user.entity';
test('서비스가 user를 만든다', async () => {
// User.save를 어떻게 mock?
jest.spyOn(User, 'save').mockResolvedValue({ id: 1 } as any);
// ← 정적 메서드 mocking은 *지저분*하다
await service.createUser({ name: 'Alice' });
expect(User.save).toHaveBeenCalled();
});
// vs DataMapper
test('서비스가 user를 만든다 (DataMapper)', async () => {
const userRepo = { save: jest.fn().mockResolvedValue({ id: 1 }) };
const service = new UserService(userRepo as any);
// ← *깔끔하게* 주입
});4) DDD/헥사고날 아키텍처
“도메인 객체는 영속성을 모른다”가 DDD의 핵심 원칙이다.
extends BaseEntity는 이 원칙을 깬다. ActiveRecord를 쓰는 순간 순수 도메인 모델은 불가능.
Insight — 왜 TypeORM은 두 모드를 모두 지원하는가
2016년 TypeORM이 시작될 때, 메인테이너 Umed Khudoiberdiev는 두 진영의 표현력을 모두 보여주고 싶었다. Rails·Django 출신 개발자에게는 ActiveRecord가, Java·Doctrine 출신에게는 DataMapper가 익숙하다.
→ 결과: 두 모드 공존. 하지만 시간이 흐르며,
- NestJS의 폭발적 성장(2017+)으로 DI 컨테이너가 사실상 표준이 되었고,
- DDD 진영의 영향으로 순수 도메인 모델이 가치 있는 것으로 인식되며,
- 공식 문서가 명시적으로 DataMapper를 권장하게 되었다.
BaseEntity의 알려지지 않은 진실
// typeorm 소스 코드 (요약)
export class BaseEntity {
private static dataSource?: DataSource;
static useDataSource(dataSource: DataSource) {
BaseEntity.dataSource = dataSource;
}
static getRepository<T>(this: { new (): T }): Repository<T> {
if (!BaseEntity.dataSource) throw new Error("DataSource not set");
return BaseEntity.dataSource.getRepository(this);
}
save(): Promise<this> {
return BaseEntity.getRepository().save(this);
}
// ...
}→ BaseEntity는 얇은 wrapper다. DataSource를 정적 변수에 들고 있다가 Repository로 위임. 그 한 줄의 단순함이 모든 함정의 출처이기도 하다.
재미있는 사실 — NestJS는 ActiveRecord를 거의 봉쇄한다
@nestjs/typeorm의 TypeOrmModule.forFeature([User])는 Repository provider를 만들어 DI 컨테이너에 넣는다. 사용자는 @InjectRepository(User)로 받는다. 이 흐름 안에 BaseEntity.useDataSource를 호출할 자연스러운 자리가 없다.
NestJS 공식 문서: “NestJS uses the DataMapper pattern by default” — 명시적이다.
→ NestJS를 쓰는 한 ActiveRecord는 사실상 금지된 길이다.
요약
BaseEntity상속 모드는 짧고 친숙하다 — Rails·Django·Eloquent의 멘탈 모델이 그대로 동작. 하지만:
- 엔티티가 DataSource를 안다 → 테스트·다중 DB·DDD에 약하다.
- NestJS의 DI와 우회 관계를 만든다.
- 정적 메서드 mocking이 지저분하다.
언제 써도 되나:
- CLI 스크립트, 일회성 마이그레이션, 튜토리얼.
- Express + 1 DB의 단순 CRUD.
언제 쓰면 안 되나:
- NestJS 프로젝트, 다중 DataSource, DDD/헥사고날.
한 줄 규칙: “앱이 1주 살 거면 ActiveRecord, 1년 살 거면 DataMapper.”
챕터 끝. 다음 챕터 04 — QueryBuilder에서 find가 풀 수 없는 복잡 쿼리의 세계로.