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();       // ← restore

useDataSource — 정적 DataSource 바인딩

BaseEntity전역 DataSource를 참조한다. 이를 명시적으로 설정해야 한다.

// 앱 부트스트랩
const dataSource = await new DataSource({ ... }).initialize();
BaseEntity.useDataSource(dataSource);    // ★ 전역 바인딩
 
// 이제 어디서든:
await User.find();   // dataSource를 *간접 참조*

→ 이 한 줄을 깜빡하면 모든 User.find()런타임에 에러. 이게 ActiveRecord의 첫 함정이다.


What — 짧음의 대가 매트릭스

기준ActiveRecordDataMapper
코드 길이짧다길다
학습 곡선완만가파름
DataSource 의존전역 (BaseEntity.useDataSource)명시적 주입
다중 DataSourceuseDataSource 호출 매번 필요자연스러움
단위 테스트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.useDataSourceDI를 우회한다. 두 패턴이 충돌한다.

// 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.useDataSourcestatic 변수다. 동시성 환경에서 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/typeormTypeOrmModule.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가 풀 수 없는 복잡 쿼리의 세계로.