🗄️ TypeORM3. Repository & DataMapper01 — DataMapper vs ActiveRecord

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/typeormActiveRecord 모드를 거의 지원하지 않는다. forFeature([User])이 Repository를 만들어 주입하는 구조 자체가 DataMapper 가정 위에 서 있다. NestJS를 쓰는 한 ActiveRecord는 사실상 봉쇄된다.


요약

두 패턴은 취향이 아니라 다른 설계 원칙이다. ActiveRecord: 엔티티 = 영속성. 짧고 빠르지만 테스트·다중 DB에 약하다. DataMapper: 엔티티 ⊥ 영속성. 길지만 도메인 객체가 순수해진다. TypeORM은 둘 다 지원하지만 DataMapper를 권장한다 — 그리고 섞지 말라.

다음: 02 — Repository API — DataMapper의 게이트웨이가 제공하는 11개 메서드의 정확한 의미.