MikroORM 시드 스크립트
개발 환경에서 데이터베이스를 초기화하고 나면 텅 빈 테이블만 남는다. 기능을 테스트하려면 최소한의 샘플 데이터가 필요한데, 매번 수동으로 INSERT 쿼리를 작성하거나 Postman으로 API를 하나씩 호출하는 건 비효율적이다. 팀원이 새로 합류하면 "로컬 DB 셋업"에 반나절을 쓰는 일도 생긴다.
시딩(Seeding)은 이 문제를 구조적으로 해결한다. 코드로 정의된 시드 스크립트를 실행하면 데이터베이스에 일관된 초기 데이터가 자동으로 삽입된다. 시드 스크립트는 버전 관리에 포함되니까 팀 전체가 동일한 개발 데이터를 공유할 수 있고, CI 환경에서 테스트용 데이터를 자동으로 세팅할 수도 있다.
MikroORM은 @mikro-orm/seeder 패키지를 통해 시딩 시스템을 제공한다. Seeder 클래스와 Factory 패턴을 조합해서 엔티티 간 관계까지 포함한 복잡한 데이터 구조를 선언적으로 생성할 수 있다.
설치와 설정
시더를 사용하려면 패키지 설치 후 ORM 설정에 SeedManager 확장을 등록해야 한다.
npm install @mikro-orm/seeder
// mikro-orm.config.ts
import { SeedManager } from '@mikro-orm/seeder';
export default defineConfig({
extensions: [SeedManager],
seeder: {
path: './seeders', // 컴파일된 JS 시더 경로
pathTs: './src/seeders', // TS 소스 시더 경로
defaultSeeder: 'DatabaseSeeder', // 기본 시더 클래스명
glob: '!(*.d).{js,ts}', // 파일 매칭 패턴
emit: 'ts', // CLI가 생성하는 파일 형식
},
});
path와 pathTs의 관계는 마이그레이션 설정과 동일하다. 개발 시에는 pathTs의 TypeScript 파일을 직접 실행하고, 프로덕션에서는 path의 컴파일된 JS 파일을 사용한다. ts-node나 tsx 같은 런타임이 감지되면 자동으로 pathTs를 우선 참조한다.
Seeder 클래스 기본 구조
시더 클래스는 Seeder를 상속하고 run 메서드 하나만 구현하면 된다.
import { EntityManager } from '@mikro-orm/core';
import { Seeder } from '@mikro-orm/seeder';
import { User } from '../entities/user.entity';
import { Role } from '../entities/role.entity';
export class DatabaseSeeder extends Seeder {
async run(em: EntityManager): Promise<void> {
const adminRole = em.create(Role, {
name: 'admin',
permissions: ['user:read', 'user:write', 'admin:access'],
});
const userRole = em.create(Role, {
name: 'user',
permissions: ['user:read'],
});
em.create(User, {
name: '관리자',
email: 'admin@example.com',
role: adminRole,
});
em.create(User, {
name: '테스트 유저',
email: 'user@example.com',
role: userRole,
});
}
}
여기서 알아야 할 핵심이 하나 있다. 시더 안에서 받는 em은 persistOnCreate가 자동으로 활성화되어 있다. ORM 설정에서 이 옵션을 꺼놨더라도 시더 내부에서는 강제로 켜진다. 그래서 em.create()를 호출하면 별도로 em.persist()를 호출할 필요가 없다. 하지만 new Entity()로 엔티티 생성자를 직접 사용하면 em.persist()를 명시적으로 호출해야 한다.
시더 실행이 끝나면 MikroORM이 자동으로 flush()와 clear()를 호출한다. 즉, run 메서드 안에서는 데이터를 만들기만 하면 된다. DB에 쓰는 것과 Identity Map 정리는 프레임워크가 처리한다.
CLI로 시더 생성과 실행
MikroORM CLI는 시더 관련 명령어를 제공한다.
# 시더 클래스 생성
npx mikro-orm seeder:create DatabaseSeeder
npx mikro-orm seeder:create user-roles # → UserRolesSeeder 클래스 생성
# 시더 실행 (기본: DatabaseSeeder)
npx mikro-orm seeder:run
# 특정 시더만 실행
npx mikro-orm seeder:run --class=UserRolesSeeder
seeder:create에 전달하는 이름은 클래스명, 하이픈 케이스, 단순 이름 어느 것이든 상관없다. CLI가 알아서 PascalCase 클래스명으로 변환해서 파일을 생성한다.
마이그레이션과 함께 사용할 때 특히 유용한 조합이 있다:
# DB 드롭 → 마이그레이션 전체 실행 → 시딩
npx mikro-orm migration:fresh --seed
# DB 스키마 재생성 → 시딩
npx mikro-orm schema:fresh --seed
# 특정 시더 지정
npx mikro-orm migration:fresh --seed TestSeeder
migration:fresh --seed는 "로컬 DB를 완전히 초기화하고 깨끗한 상태에서 시작하고 싶을 때" 가장 많이 쓰는 명령이다. 데이터베이스를 드롭하고, 마이그레이션을 처음부터 전부 실행하고, 시드 데이터까지 넣어주는 올인원 명령이다.
시더 분리와 호출 체인
프로젝트가 커지면 하나의 시더에 모든 데이터를 넣는 건 유지보수가 어렵다. this.call()로 시더를 분리하고 체인으로 연결할 수 있다.
export class DatabaseSeeder extends Seeder {
run(em: EntityManager): Promise<void> {
return this.call(em, [
RoleSeeder,
UserSeeder,
CategorySeeder,
ProductSeeder,
OrderSeeder,
]);
}
}
this.call()은 배열에 나열된 순서대로 시더를 실행한다. 이 순서가 중요한 이유는 외래 키 관계 때문이다. UserSeeder가 Role 엔티티를 참조한다면 RoleSeeder가 먼저 실행되어야 한다. 마이그레이션의 실행 순서와 같은 원리다.
공유 컨텍스트 (Shared Context)
시더를 분리하면 시더 간에 생성된 엔티티를 공유해야 하는 상황이 생긴다. RoleSeeder에서 만든 역할 엔티티를 UserSeeder에서 참조하고 싶다면? this.call()이 자동으로 생성하는 공유 컨텍스트 객체를 사용하면 된다.
import { EntityManager, Dictionary } from '@mikro-orm/core';
import { Seeder } from '@mikro-orm/seeder';
export class RoleSeeder extends Seeder {
async run(em: EntityManager, context: Dictionary): Promise<void> {
context.adminRole = em.create(Role, {
name: 'admin',
permissions: ['user:read', 'user:write', 'admin:access'],
});
context.userRole = em.create(Role, {
name: 'user',
permissions: ['user:read'],
});
}
}
export class UserSeeder extends Seeder {
async run(em: EntityManager, context: Dictionary): Promise<void> {
em.create(User, {
name: '관리자',
email: 'admin@example.com',
role: context.adminRole, // RoleSeeder에서 만든 엔티티 참조
});
em.create(User, {
name: '일반 사용자',
email: 'user@example.com',
role: context.userRole,
});
}
}
run 메서드의 두 번째 파라미터 context는 Dictionary 타입(사실상 Record<string, any>)이다. this.call()이 내부적으로 빈 객체를 하나 만들어서 모든 시더의 run 메서드에 동일한 참조를 전달한다. 시더 A에서 context.something = entity로 저장하면, 다음에 실행되는 시더 B에서 context.something으로 꺼내 쓸 수 있는 구조다.
타입 안전성이 부족하다는 단점은 있다. context.adminRole이 존재하는지 컴파일 타임에 보장할 수 없다. 규모가 큰 프로젝트라면 컨텍스트 타입을 별도로 정의하는 것도 방법이다:
interface SeedContext extends Dictionary {
adminRole: Role;
userRole: Role;
categories: Category[];
}
export class UserSeeder extends Seeder {
async run(em: EntityManager, context: SeedContext): Promise<void> {
// context.adminRole의 타입이 Role로 추론됨
}
}
Entity Factory
시더에서 em.create()로 하나씩 만드는 건 소수의 데이터에는 괜찮다. 하지만 "100명의 유저"나 "500개의 상품"처럼 대량의 테스트 데이터가 필요할 때는 팩토리가 필요하다.
팩토리는 엔티티의 기본 속성값을 정의하는 클래스다. 호출할 때마다 랜덤하면서도 유효한 데이터를 가진 엔티티 인스턴스를 생성한다.
팩토리 정의
import { Factory } from '@mikro-orm/seeder';
import { faker } from '@faker-js/faker';
import { User } from '../entities/user.entity';
export class UserFactory extends Factory<User> {
model = User;
definition(): Partial<User> {
return {
name: faker.person.fullName(),
email: faker.internet.email(),
age: faker.number.int({ min: 18, max: 65 }),
bio: faker.lorem.sentence(),
createdAt: faker.date.past(),
};
}
}
Factory<T>를 상속하고 두 가지를 구현한다:
model: 어떤 엔티티를 만들 것인지definition(): 기본 속성값을 반환하는 메서드
@faker-js/faker는 MikroORM v6부터 별도 설치가 필요하다. 이전에는 시더 패키지에서 re-export 했지만 지금은 직접 의존성으로 추가해야 한다.
npm install @faker-js/faker --save-dev
make vs create
팩토리에는 두 가지 생성 방식이 있다:
const factory = new UserFactory(orm.em);
// make: 엔티티 인스턴스만 생성 (DB에 저장하지 않음)
const user = factory.makeOne();
const users = factory.make(10);
// create: 생성 + persistAndFlush (DB에 즉시 저장)
const user = await factory.createOne();
const users = await factory.create(10);
make는 메모리에만 엔티티를 만든다. 시더 내부에서는 어차피 run 종료 시 자동 flush 되니까 make를 쓰는 게 일반적이다. create는 팩토리를 시더 바깥에서 독립적으로 사용할 때, 예를 들어 테스트 코드에서 즉시 DB에 넣어야 할 때 쓴다.
속성 오버라이드
팩토리가 생성하는 기본값 중 특정 필드만 바꾸고 싶을 때:
// 이름만 고정하고 나머지는 랜덤
const admin = factory.makeOne({
name: '시스템 관리자',
email: 'admin@example.com',
});
// 10명 모두 같은 역할로 생성
const moderators = factory.make(10, {
role: moderatorRole,
});
오버라이드에 전달한 속성만 대체되고, 나머지는 definition()에서 정의한 랜덤값이 사용된다. 테스트에서 "특정 조건의 데이터"를 만들 때 유용하다. 예를 들어 "나이가 20대인 유저 목록"을 테스트하고 싶으면 factory.make(5, { age: 25 })처럼 쓸 수 있다.
팩토리에서 관계 설정
실제 데이터는 거의 항상 다른 엔티티와 관계를 맺고 있다. 팩토리에서 관계를 설정하는 방법은 두 가지다.
each()로 외부에서 설정
each()를 체이닝하면 각 엔티티가 생성된 직후 변환 함수를 실행할 수 있다.
// ManyToOne: 각 Book에 랜덤 Author 연결
const books = new BookFactory(em)
.each(book => {
book.author = new AuthorFactory(em).makeOne();
})
.make(20);
// OneToMany / ManyToMany: 각 Author에 Book 5개씩 연결
const authors = new AuthorFactory(em)
.each(author => {
author.books.set(new BookFactory(em).make(5));
})
.make(3);
each()의 장점은 팩토리 정의를 건드리지 않고 관계를 유연하게 조합할 수 있다는 점이다. 같은 BookFactory를 쓰면서 어떤 시더에서는 Author를 새로 만들고, 다른 시더에서는 기존 Author를 연결하는 식의 조합이 가능하다.
definition() 내부에서 설정
팩토리 자체에 관계 생성 로직을 포함시킬 수도 있다. 추가 파라미터를 제네릭 타입으로 정의하면 팩토리 호출 시점에 관계 데이터의 수량이나 내용을 제어할 수 있다.
export class AuthorFactory extends Factory<
Author,
EntityData<Author> & { booksCount?: number }
> {
model = Author;
definition(
params?: EntityData<Author> & { booksCount?: number }
): EntityData<Author> {
const name = params?.name ?? faker.person.fullName();
const books = params?.books ?? (
[...Array(params?.booksCount ?? 0)].map((_, i) =>
new BookFactory(this.em).makeOne({
title: `${name} 시리즈 - ${i + 1}편`,
})
)
);
return {
...params,
name,
books,
};
}
}
// 사용
const author = await new AuthorFactory(em).createOne({ booksCount: 4 });
이 방식은 "Author를 만들면 기본적으로 Book도 같이 만든다"는 의미를 팩토리 정의에 내재시킨다. 호출하는 쪽에서는 booksCount만 전달하면 되니까 사용이 간편하다. 다만 팩토리 간 의존성이 생기기 때문에, 순환 참조가 발생하지 않도록 주의해야 한다.
테스트에서의 활용
시딩 시스템의 가장 큰 수혜자는 통합 테스트다. 테스트 시작 전에 깨끗한 DB를 만들고 시드 데이터를 넣으면, 모든 테스트가 동일한 초기 상태에서 시작한다.
let orm: MikroORM;
beforeAll(async () => {
orm = await MikroORM.init({ /* ... */ });
// 스키마 재생성 (테이블 드롭 + 재생성)
await orm.schema.refreshDatabase();
// 시드 데이터 삽입
await orm.seeder.seed(DatabaseSeeder);
});
afterEach(async () => {
// 각 테스트 후 Identity Map 클리어
orm.em.clear();
});
afterAll(async () => {
await orm.close();
});
test('관리자는 모든 유저를 조회할 수 있다', async () => {
const users = await orm.em.find(User, {});
expect(users.length).toBeGreaterThan(0);
});
refreshDatabase()는 기존 스키마를 드롭하고 현재 엔티티 정의 기반으로 다시 생성한다. 마이그레이션을 거치지 않고 엔티티 메타데이터에서 바로 스키마를 만들기 때문에 테스트 속도가 빠르다.
팩토리를 테스트 내부에서 직접 사용하면 특정 시나리오에 맞는 데이터를 만들 수도 있다:
test('비활성 유저는 로그인할 수 없다', async () => {
const inactiveUser = await new UserFactory(orm.em).createOne({
isActive: false,
});
const result = await authService.login(inactiveUser.email, 'password');
expect(result.success).toBe(false);
});
em.fork()와 시딩
MikroORM에서 em.fork()는 EntityManager의 독립된 복사본을 만든다. Identity Map이 분리되기 때문에, 포크된 EM에서의 작업이 원본 EM에 영향을 주지 않는다.
시딩에서 em.fork()가 중요한 이유는 NestJS 같은 프레임워크에서 직접 시딩을 실행할 때다:
// NestJS bootstrap에서 시딩 실행
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const orm = app.get(MikroORM);
// fork된 EM으로 시딩 → 앱의 기본 EM에 영향 없음
const em = orm.em.fork();
const adminRole = em.create(Role, { name: 'admin' });
em.create(User, {
name: '초기 관리자',
email: 'admin@startup.com',
role: adminRole,
});
await em.flush();
await app.listen(3000);
}
CLI의 seeder:run을 사용하면 내부적으로 이미 포크된 EM을 전달하기 때문에 신경 쓸 필요가 없다. 하지만 애플리케이션 코드 안에서 프로그래밍 방식으로 시딩을 실행할 때는 반드시 em.fork()를 써야 한다. 그래야 시딩 과정에서 생긴 Identity Map 엔트리가 요청 처리용 EM을 오염시키지 않는다.
프로덕션 시딩
시딩이 개발/테스트 전용이라고 생각하기 쉽지만, 프로덕션에서도 초기 데이터가 필요한 경우가 있다. 기본 역할, 시스템 설정값, 국가/통화 코드 같은 마스터 데이터가 그 예다.
프로덕션 시딩의 핵심은 멱등성(idempotency)이다. 같은 시더를 여러 번 실행해도 데이터가 중복되면 안 된다:
export class ProductionSeeder extends Seeder {
async run(em: EntityManager): Promise<void> {
// upsert: 있으면 업데이트, 없으면 삽입
await em.upsert(Role, { name: 'admin', permissions: ['all'] });
await em.upsert(Role, { name: 'user', permissions: ['read'] });
await em.upsert(Role, { name: 'moderator', permissions: ['read', 'write'] });
// 또는 존재 여부 체크
const existing = await em.findOne(SystemConfig, { key: 'app.version' });
if (!existing) {
em.create(SystemConfig, { key: 'app.version', value: '1.0.0' });
}
}
}
프로덕션에서 컴파일된 JS 파일을 사용하려면 path와 pathTs를 분리 설정해야 한다:
export default defineConfig({
seeder: {
path: 'dist/seeders', // 프로덕션: 컴파일된 JS
pathTs: 'src/seeders', // 개발: TypeScript 소스
},
});
실전 시딩 구조 예시
규모가 있는 프로젝트에서 시더를 어떻게 구성하는지 전체 구조를 보자:
src/seeders/
├── DatabaseSeeder.ts # 진입점 (개발용)
├── ProductionSeeder.ts # 진입점 (프로덕션 마스터 데이터)
├── RoleSeeder.ts
├── UserSeeder.ts
├── CategorySeeder.ts
├── ProductSeeder.ts
└── factories/
├── user.factory.ts
├── product.factory.ts
└── order.factory.ts
// DatabaseSeeder.ts - 개발 환경 전체 시딩
export class DatabaseSeeder extends Seeder {
run(em: EntityManager): Promise<void> {
return this.call(em, [
RoleSeeder, // 1. 역할 (의존성 없음)
UserSeeder, // 2. 유저 (Role 의존)
CategorySeeder, // 3. 카테고리 (의존성 없음)
ProductSeeder, // 4. 상품 (Category 의존)
]);
}
}
// ProductSeeder.ts - 팩토리 활용
export class ProductSeeder extends Seeder {
async run(em: EntityManager, context: Dictionary): Promise<void> {
// 카테고리별로 상품 20개씩 생성
for (const category of context.categories) {
new ProductFactory(em)
.each(product => {
product.category = category;
})
.make(20);
}
}
}
시더 실행 순서는 외래 키 의존 관계를 반영해야 한다. 부모 엔티티를 먼저 생성하고, 자식 엔티티가 그것을 참조하는 순서다. this.call()의 배열 순서가 곧 실행 순서이므로 이 점을 항상 의식해서 배치해야 한다.
주의사항
Faker 로케일: @faker-js/faker는 한국어 로케일을 지원한다. 한국 서비스를 개발한다면 한국어 더미 데이터를 생성할 수 있다.
import { faker } from '@faker-js/faker/locale/ko';
definition(): Partial<User> {
return {
name: faker.person.fullName(), // 한국식 이름 생성
};
}
시딩 성능: 대량의 데이터를 시딩할 때 em.create()를 수천 번 호출하면 Identity Map이 비대해진다. 배치 단위로 em.flush()와 em.clear()를 호출해서 메모리를 관리하는 게 좋다:
for (let i = 0; i < 10000; i++) {
em.create(Product, { /* ... */ });
if (i % 500 === 0) {
await em.flush();
em.clear();
}
}
await em.flush();
시딩 vs 마이그레이션: 스키마 변경은 마이그레이션, 데이터 삽입은 시딩이다. 가끔 마이그레이션에서 데이터를 넣어야 하는 경우가 있는데(예: 새 컬럼에 기존 데이터 변환), 이건 데이터 마이그레이션이지 시딩이 아니다. 시딩은 "처음부터 있어야 하는 데이터"에 쓴다.
왜 MikroORM Seeder인가
TypeORM에는 공식 시딩 시스템이 없다. typeorm-seeding 같은 커뮤니티 패키지를 써야 하는데, 유지보수가 불안정하고 팩토리 패턴도 제한적이다. Prisma는 prisma db seed 명령을 제공하지만 팩토리 클래스나 시더 체인 같은 구조화된 패턴은 직접 만들어야 한다.
MikroORM Seeder는 Seeder 클래스 → Factory → this.call() 체인 → 공유 컨텍스트까지 일관된 구조를 ORM 레벨에서 제공한다. migration:fresh --seed 한 줄로 DB 초기화부터 시딩까지 끝나는 것도 다른 ORM에서는 별도 스크립트를 짜야 하는 부분이다.
정리
- Seeder 클래스의
run에서는em.create()만 하면 되고, flush와 Identity Map 정리는 프레임워크가 자동 처리한다 - Factory의
definition()+each()+ 오버라이드 조합으로 랜덤 데이터부터 특정 조건 데이터까지 유연하게 생성할 수 있다 - 프로덕션 시딩은 멱등성이 핵심이고,
em.upsert()나 존재 여부 체크를 통해 중복 삽입을 방지해야 한다
관련 문서
- Entity Manager - EntityManager 기본 사용법과 Unit of Work
- Migration - 스키마 변경 관리
- Testcontainers - 통합 테스트 환경 구성