01 — DataMapper vs ActiveRecord
질문: “엔티티가 자기
save()를 호출하는 게 자연스러운가, 아니면 별도 Repository가 부르는 게 자연스러운가?” 한 줄 답: 둘은 취향이 아니라 다른 설계 원칙이다. ActiveRecord는 엔티티 = 영속성, DataMapper는 엔티티 ⊥ 영속성. TypeORM은 둘 다 지원하지만 — DataMapper를 권장한다.
Why — 왜 두 패턴이 존재하는가
1996년 Martin Fowler는 Analysis Patterns에서 영속성과 도메인 객체를 분리할 것인가 통합할 것인가라는 질문을 던졌다. 2002년 Patterns of Enterprise Application Architecture에서 이 두 답을 정식 패턴으로 못 박았다.
| 패턴 | 핵심 한 줄 | 대표 |
|---|---|---|
| ActiveRecord | ”객체 한 개 = DB row 한 개. 객체가 자기 CRUD를 안다.” | Rails ActiveRecord, Django ORM, Laravel Eloquent |
| DataMapper | ”도메인 객체는 POJO. 영속성은 별도 레이어(Mapper/Repository)가 책임.” | Hibernate, TypeORM(기본), MikroORM, Doctrine |
// ActiveRecord 스타일
const user = new User();
user.name = "Alice";
await user.save(); // 엔티티가 자기 save를 안다
// DataMapper 스타일
const user = new User();
user.name = "Alice";
await userRepository.save(user); // Repository가 한다, 엔티티는 모른다차이는 한 줄처럼 보이지만, 큰 코드베이스에서 의미가 갈린다.
How — 두 패턴이 강제하는 구조
ActiveRecord — 엔티티에 모든 것이
@Entity()
export class User extends BaseEntity { // ★ BaseEntity 상속
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
// 어디서든:
const user = await User.findOne({ where: { id: 1 } });
user.name = "Bob";
await user.save();
await user.remove();- 장점: 짧다. 학습 곡선이 거의 없다.
- 단점:
- 엔티티가 DataSource를 안다 — 다중 DB 환경에서 곤란.
- 단위 테스트가 어렵다 —
save()가 항상 DB를 친다. - DDD 진영의 순수 도메인 모델을 만들 수 없다.
DataMapper — 엔티티는 모른다
@Entity()
export class User { // ★ BaseEntity 안 받는다
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
// 서비스 계층:
const user = await userRepository.findOneBy({ id: 1 });
user.name = "Bob";
await userRepository.save(user);
await userRepository.remove(user);- 장점:
- 엔티티가 순수 — 테스트 시
new User()후 메서드 호출만으로 검증 가능. - 영속성 계층 교체가 가능 — 같은 엔티티를 다른 Repository로 영속화 가능.
- DDD/헥사고날의 포트-어댑터 구조와 맞물린다.
- 엔티티가 순수 — 테스트 시
- 단점: 항상 Repository를 들고 다녀야 함 — 보일러플레이트가 늘어난다.
What — TypeORM이 둘 다 지원하는 실제 모습
TypeORM은 기본이 DataMapper, 옵션으로 ActiveRecord다.
| 기준 | DataMapper (기본) | ActiveRecord |
|---|---|---|
| 엔티티 상속 | 없음 | extends BaseEntity |
| 저장 호출 | repo.save(user) | user.save() |
| DataSource 의존 | Repository에 주입 | BaseEntity 정적 메서드가 전역 DataSource 참조 |
| NestJS 통합 | @InjectRepository(User) | 직접 호출 |
| 다중 DataSource | 자연스러움 | useDataSource() 명시적 호출 필요 |
| 테스트 친화도 | 높음 | 낮음 |
| 코드 길이 | 길다 | 짧다 |
공식 문서가 명시: “In TypeORM you can use both Data Mapper and Active Record patterns. However, we recommend Data Mapper for medium and larger sized applications.”
What-if — 둘을 섞어 쓰면
가장 위험한 함정이다.
// ActiveRecord로 시작
@Entity()
export class User extends BaseEntity { ... }
// 어느 날 NestJS로 옮기면서 DataMapper도 들임
constructor(
@InjectRepository(User) private userRepo: Repository<User>,
) {}
// 어떤 곳은 user.save(), 어떤 곳은 userRepo.save(user)- 두 호출이 다른 DataSource를 참조할 수 있다 — 디버깅 지옥.
- 코드 리뷰에서 어느 스타일이 맞는지 매번 다툰다.
- 테스트 시 BaseEntity의 정적 DataSource를 mocking 못 한다.
규칙: 프로젝트 단위로 한쪽만 골라라. TypeORM 공식이 DataMapper를 권장하는 가장 큰 이유는 섞이지 않도록 명시적이기 때문이다.
Hibernate와의 차이 — Unit of Work 없음
ActiveRecord/DataMapper 분류와는 별개로, TypeORM은 Hibernate의 Session = Unit of Work 패턴을 채택하지 않았다.
| 기능 | Hibernate (Java) | TypeORM |
|---|---|---|
| dirty checking | ✓ 자동 (entity manager가 변경 추적) | ✗ 없음 |
| flush 시점 | 트랜잭션 commit 시 자동 | 매 save() 호출 시 즉시 |
| identity map | ✓ session 안에서 같은 PK = 같은 객체 | △ 부분 (cache: true 시) |
→ TypeORM에서는 user.name = "Bob"만 해서는 DB가 바뀌지 않는다. 반드시 repo.save(user)를 명시적으로 호출해야 한다 (04 문서 자세히).
Insight — Fowler의 두 패턴이 프레임워크 진영을 가른다
- ActiveRecord 진영은 Ruby·Python·PHP 같은 동적 언어와 짝을 이뤘다 — 메타프로그래밍으로 엔티티에
save를 생으로 박는 게 쉽다. - DataMapper 진영은 Java·TypeScript 같은 정적 언어와 짝이다 — 컴파일러가 순수 클래스를 좋아하고, AOP/리플렉션으로 별도 매퍼를 만드는 게 자연스럽다.
TypeORM이 DataMapper를 기본으로 한 건 우연이 아니다 — TypeScript의 데코레이터 + reflect-metadata가 별도 매퍼를 짜기에 알맞은 도구였기 때문이다.
재미있는 사실: NestJS의
@nestjs/typeorm은 ActiveRecord 모드를 거의 지원하지 않는다.forFeature([User])이 Repository를 만들어 주입하는 구조 자체가 DataMapper 가정 위에 서 있다. NestJS를 쓰는 한 ActiveRecord는 사실상 봉쇄된다.
요약
두 패턴은 취향이 아니라 다른 설계 원칙이다. ActiveRecord: 엔티티 = 영속성. 짧고 빠르지만 테스트·다중 DB에 약하다. DataMapper: 엔티티 ⊥ 영속성. 길지만 도메인 객체가 순수해진다. TypeORM은 둘 다 지원하지만 DataMapper를 권장한다 — 그리고 섞지 말라.
다음: 02 — Repository API — DataMapper의 게이트웨이가 제공하는 11개 메서드의 정확한 의미.