1. MikroORM 개요
MikroORM은 Node.js 환경에서 데이터베이스 상호작용을 간소화하는 TypeScript ORM 라이브러리입니다.
데이터 매퍼, Unit of Work, Identity Map 패턴을 기반으로 설계되었으며, MongoDB, MySQL, MariaDB, PostgreSQL, SQLite 등 다양한 데이터베이스를 지원합니다.
주요 기능
•
깔끔한 엔티티 정의
•
Identity Map과 엔티티 참조
•
엔티티 생성자 활용
•
관계 모델링과 컬렉션
•
Unit of Work
•
트랜잭션 지원
•
캐스케이딩 영속성과 삭제
•
필터와 쿼리 빌더
•
populate를 통한 중첩 구조 로딩
•
스키마와 엔티티 생성기
2. Unit of Work 패턴
Unit of Work 패턴은 MikroORM의 핵심 설계 패턴으로서, 엔티티의 변경사항(삽입, 수정, 삭제)을 관리하고 단일 트랜잭션에서 영속화를 조정합니다.
핵심 개념
•
Unit of Work는 다음 세 가지 주요 엔티티 상태를 추적
◦
새로운 엔티티 (삽입 예정)
◦
수정된 엔티티 (업데이트 예정)
◦
제거된 엔티티 (삭제 예정)
이러한 변경사항은 비즈니스 트랜잭션 중에 수집되며, Unit of Work 커밋 시 원자적으로 영속화됩니다. 이는 데이터 일관성을 보장하고 데이터베이스 작업을 최소화합니다.
변경 감지 메커니즘
MikroORM은 정교한 변경 감지 메커니즘을 사용합니다. 엔티티를 데이터베이스에서 로드할 때 해당 시점의 스냅샷을 저장하고, flush() 호출 시 현재 상태와 비교하여 변경사항을 파악합니다.
// 변경 감지 예시
const article = await em.findOne(Article, 1);
article.content = '업데이트된 내용';
await em.flush(); // 변경된 필드만 업데이트
TypeScript
복사
3. 영속화(Persist) 이해하기
영속화(persist)는 엔티티를 EntityManager에 의해 관리되도록 등록하는 과정입니다. persist 호출은 즉시 데이터베이스에 쓰기를 수행하지 않으며, 대신 엔티티를 추적하고 후속 flush 호출 시 데이터베이스에 저장하도록 표시합니다
const user = new User('John Doe');
em.persist(user); // 관리 상태로 표시됨
TypeScript
복사
persist 메서드는 엔티티가 관리 상태로 들어가는 것을 의미하며, 이 상태에서 MikroORM은 엔티티의 변경사항을 추적할 수 있습니다
4. Flush 작업 이해하기
flush는 관리되는 모든 엔티티의 변경사항을 계산하고 이를 데이터베이스에 적용하는 과정입니다. em.flush()를 호출하면 모든 계산된 변경사항이 데이터베이스 트랜잭션 내에서 쿼리됩니다(해당 드라이버가 지원하는 경우)
const user = await em.findOne(User, 1);
user.email = 'foo@bar.com';
const car = new Car();
user.cars.add(car);
// 양방향 캐스케이딩 덕분에 user 엔티티만 영속화하면 됨
// flush는 트랜잭션을 생성하고, 새 car를 삽입하고, user의 이메일을 업데이트함
await em.flush();
TypeScript
복사
flush는 변경된 엔티티와 속성만을 데이터베이스에 쿼리하여 성능을 최적화합니다. 변경사항이 없으면 트랜잭션도 시작되지 않습니다
5. Persist 와 Flush 사용 시점
Persist 사용 시점
•
생성자로 새 엔티티를 생성했을 때
const entity = new Entity();
Object.assign(entity, {});
em.persiste(entity); // 수동 호출 필요
TypeScript
복사
•
엔티티를 EntityManager에 의해 관리되도록 표시하고 싶을 때
◦
persist()는 새로운 엔티티를 Identity Map에 등록하기 위한 메서드입니다.
◦
DB에서 조회된 기존 엔티티는 이미 Managed 상태이므로 persist() 호출이 필요 없습니다.
•
여러 엔티티를 수정하고 이후에 한 번에 플러시하고 싶을 때
Flush 사용 시점
•
모든 변경사항을 데이터베이스에 적용하고 싶을 때
•
트랜잭션 경계를 제어하고 싶을 때
•
변경사항들이 준비되어 단일 트랜잭션으로 실행하고 싶을 때
// 여러 엔티티 처리를 위한 패턴
const users = [];
for (let i = 0; i < 1000; i++) {
const user = new User(`user${i}@example.com`);
users.push(user);
em.persist(user);
}
// 모든 1000명의 사용자가 효율적인 배치로 삽입됨
await em.flush();
TypeScript
복사
이 패턴은 트랜잭션 경계를 제어하는 데 유용하며, em.persist()를 호출하여 변경사항을 표시하고 준비가 되면 flush()를 호출하여 트랜잭션 내에서 실행할 수 있습니다
6. EntityManager.create 사용 시점
•
v5.5부터 persistOnCreate 옵션의 기본값이 true로 변경되었습니다.
•
em.create()로 생성된 엔티티는 별도의 em.persist() 호출 없이 자동으로 관리 상태가 되고 persist 예약(INSERT 예정)됩니다.
•
원치 않는 경우, 옵션으로 { persist: false }를 설정하거나 전역 설정으로 persistOnCreate: false를 명시해야 합니다
Creates new instance of given entity and populates it with given data. The entity constructor will be used unless you provide { managed: true } in the options parameter. The constructor will be given parameters based on the defined constructor of the entity. If the constructor parameter matches a property name, its value will be extracted from data. If no matching property exists, the whole data parameter will be passed. This means we can also define constructor(data: Partial<T>) and em.create() will pass the data into it (unless we have a property named data too).
The parameters are strictly checked, you need to provide all required properties. You can use OptionalProps symbol to omit some properties from this check without making them optional. Alternatively, use partial: true in the options to disable the strict checks for required properties. This option has no effect on runtime.
The newly created entity will be automatically marked for persistence via em.persist unless you disable this behavior, either locally via persist: false option, or globally via persistOnCreate ORM config option.
EntityManager.create 메서드는 새 엔티티 인스턴스를 생성하는 유틸리티 메서드입니다. 이는 EntityFactory의 단축형으로, ‘new’ 연산자를 통해 수동으로 클래스를 인스턴스화하는 것과 유사합니다
// 기본 인스턴스화
const user = new User('John Doe');
// em.create를 통한 인스턴스화
const user = em.create(User, { name: 'John Doe', email: 'john@example.com' });
await em.flush();
TypeScript
복사
em.create의 주요 이점은 엔티티 생성자와 파라미터를 자동으로 감지하고 존중한다는 것입니다. 엔티티 속성 이름과 동일한 이름의 파라미터를 사용해야 합니다
7. AsyncLocalStorage와 MikroORM에서의 동작
AsyncLocalStorage는 Node.js의 코어 기능으로, 비동기 호출 전반에 걸쳐 컨텍스트를 추적합니다. 웹 요청이나 기타 비동기 실행 기간 동안 데이터를 저장할 수 있게 해주며, 다른 언어의 스레드-로컬 스토리지와 유사합니다
const { AsyncLocalStorage } = require('node:async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();
function logWithId(msg) {
const id = asyncLocalStorage.getStore();
console.log(`${id !== undefined ? id : '-'}:`, msg);
}
// 요청 처리 중 ID 유지
asyncLocalStorage.run(requestId, () => {
logWithId('시작');
// 비동기 작업...
setImmediate(() => {
logWithId('종료'); // 여전히 같은 ID에 접근 가능
});
});
TypeScript
복사
MikroORM에서 AsyncLocalStorage는 RequestContext 헬퍼를 통해 사용됩니다. 이 헬퍼는 요청 컨텍스트를 격리하여 각 요청마다 고유한 EntityManager 인스턴스(fork)를 사용할 수 있게 합니다
// 미들웨어에서 요청 컨텍스트 생성
app.use((req, res, next) => {
RequestContext.create(orm.em, next);
});
app.get('/', async (req, res) => {
// 비동기 컨텍스트에서 자동으로 fork 사용
const authors = await orm.em.find(Book, {});
res.json(authors);
});
TypeScript
복사
내부적으로 EntityManager의 모든 메서드는 먼저 em.getContext()를 호출하여 현재 컨텍스트의 EntityManager를 사용합니다. 이로써 요청별로 독립된 Identity Map을 유지할 수 있습니다
8. MongoDB 트랜잭션 사용 시 주의사항
MongoDB와 함께 MikroORM의 트랜잭션 기능을 사용할 때는 여러 제약사항을 고려해야 합니다
주요 제약사항
1.
레플리카 셋 필요: MongoDB에서 트랜잭션을 사용하려면 레플리카 셋 구성이 필요합니다
const orm = await MikroORM.init({
entities: [Author, Book, ...],
clientUrl: 'mongodb://localhost:27017,localhost:27018,localhost:27019/my-db-name?replicaSet=rs0',
implicitTransactions: true, // 기본값은 false
});
TypeScript
복사
2.
시간 제한: MongoDB의 기본 트랜잭션 제한은 60초이며, 이를 초과하면 자동으로 중단됩니다
3.
문서 수정 제한: 모범 사례로, 트랜잭션 내에서 1,000개 이상의 문서를 수정하지 않는 것이 좋습니다
4.
캐시 압력: 장시간 실행되는 트랜잭션은 WiredTiger 스토리지 엔진 캐시에 높은 압력을 줄 수 있습니다
5.
다중 샤드 트랜잭션: 여러 샤드에 영향을 미치는 트랜잭션은 네트워크를 통해 작업이 조정되어야 하므로 더 많은 성능 비용이 발생합니다
권장사항
•
트랜잭션을 더 작은 부분으로 나누어 시간 제한 내에 실행되도록 합니다.
•
적절한 인덱싱을 통해 쿼리 성능을 최적화합니다.
•
트랜잭션 사용을 최소화하기 위해 데이터 스키마 설계를 고려합니다.
•
실패한 트랜잭션을 재시도하는 로직을 구현합니다.
9. Identity Map과 Request Context 자세한 소개
Identity Map
Identity Map은 MikroORM이 객체를 추적하는 패턴입니다. 데이터베이스에서 엔티티를 가져올 때마다, MikroORM은 해당 엔티티의 참조를 UnitOfWork 내에 유지합니다
주요 이점
•
엔티티 유일성: 동일한 ID를 가진 엔티티를 여러 번 요청해도 항상 같은 인스턴스를 반환합니다
const jon1 = await authorRepository.findOne(1);
const jon2 = await authorRepository.findOne(1);
console.log(jon1 === jon2); // true
TypeScript
복사
•
데이터베이스 호출 최적화: 이미 로드된 엔티티를 다시 요청할 경우, MikroORM은 Identity Map을 먼저 확인하고 데이터베이스 접근을 건너뜁니다
•
변경 감지: em.flush() 호출 시, MikroORM은 Identity Map을 통해 현재 관리되는 모든 엔티티를 확인하고 변경사항을 계산합니다
Identity Map 병합 메커니즘
1.
DB에서 조회한 데이터와 Identity Map의 엔티티를 비교
2.
동일 PK가 존재할 경우:
a.
Identity Map의 변경 사항 우선 적용
b.
DB 데이터는 무시 (단, refresh: true 옵션 사용 시 덮어씀)
i.
동일 PK의 엔티티가 이미 Identity Map에 존재할 경우, 해당 엔티티는 DB 조회 데이터를 통해 덮어씌워지지 않음
ii.
이미 메모리 내에 존재하는 엔티티를 최신 상태로 간주하여, DB 조회로 인해 변경 사항이 자동으로 사라지지 않도록 보호함
iii.
refresh: true 옵션을 통해 DB에서 가져온 데이터를 강제로 반영할 수 있음
3.
병합 과정에서 flush가 발생하지 않음
// 1. 엔티티 수정 (flush 미실행)
const author = await em.findOne(Author, 1);
author.name = 'Dirty Data';
// 2. 비-PK 조회 실행
const authors = await em.find(Author, { email: 'test@test.com' });
// 3. 결과 분석
console.log(em.getUnitOfWork().getScheduledCollections());
// ▶️ 스케줄링된 flush 작업 존재 확인 (flush가 아직 안 된 상태)
TypeScript
복사
Request Context
RequestContext는 웹 애플리케이션에서 각 요청마다 독립된 Identity Map을 유지하기 위한 헬퍼입니다
// 미들웨어로 요청 컨텍스트 설정
app.addHook('onRequest', (request, reply, done) => {
RequestContext.create(orm.em, done);
});
// 각 요청은 자체 Identity Map을 가짐
app.get('/article', async request => {
const { limit, offset } = request.query as { limit?: number; offset?: number };
const [items, total] = await db.article.findAndCount({}, {
limit, offset,
});
return { items, total };
});
TypeScript
복사
RequestContext는 Node.js의 AsyncLocalStorage API를 사용하여 비동기 호출 전반에 걸쳐 컨텍스트를 유지합니다
HTTP 요청 외에도, 큐 핸들러나 스케줄된 작업과 같은 상황에서는 @CreateRequestContext() 데코레이터를 사용할 수 있습니다
@Injectable()
export class MyService {
constructor(private readonly orm: MikroORM) { }
@CreateRequestContext()
async doSomething() {
// 별도의 컨텍스트에서 실행됨
// ...
}
}
TypeScript
복사
10. 중첩된 컨텍스트 처리
MikroORM에서 컨텍스트가 중첩될 때 발생할 수 있는 몇 가지 중요한 고려사항이 있습니다
주요 제약사항
•
데코레이터 중첩 금지: @CreateRequestContext() 데코레이터는 최상위 메서드에만 사용해야 하며, 중첩해서 사용하면 안 됩니다
•
무한 재귀 위험: 기존 컨텍스트 내에서 fork를 생성하면 Maximum call stack size exceeded 오류가 발생할 수 있습니다. 이는 fork 함수가 내부적으로 getContext를 호출하기 때문입니다
// 이런 패턴은 문제를 일으킬 수 있음
const context = () => storage.getStore();
// 컨텍스트 내에서 fork를 생성하면 무한 루프가 발생할 수 있음
TypeScript
복사
•
복잡한 애플리케이션: 복잡한 애플리케이션에서 여러 컨텍스트나 MikroORM 인스턴스가 필요한 경우, 기본 RequestContext 설정으로 문제가 발생할 수 있습니다
해결 방법
•
컨텍스트 외부에서 fork 생성: 컨텍스트 외부에서 fork를 생성하고 저장하는 방식을 사용합니다
•
커스텀 AsyncLocalStorage 인스턴스: 자체 AsyncLocalStorage 인스턴스를 생성하고 컨텍스트 옵션을 통해 MikroORM에 제공합니다
const storage = new AsyncLocalStorage<EntityManager>();
const orm = await MikroORM.init({
context: () => storage.getStore(),
// ...
});
app.use((req, res, next) => {
storage.run(orm.em.fork({ useContext: true }), next);
});
TypeScript
복사
•
작업 분할: 서로 다른 컨텍스트를 필요로 하는 작업을 별도의 메서드나 서비스로 분리합니다
예시 케이스
동일 엔티티를 트랜잭션에서 처리한 경우
// 1. 초기 조회 및 변경
const author1 = await authorRepository.findOne(1); // DB에서 '원본' 값 로드
author1.name = 'Wow'; // 변경 사항 기록 (아직 DB 반영 X)
// 2. 트랜잭션 블록
em.transactional(async () => {
const author = await authorRepository.findOne(1); // Identity Map에서 동일 인스턴스 획득
author.name = 'Hello'; // 즉시 author1에도 반영됨
}); // 트랜잭션 종료 시 암시적 flush 발생 → DB에 'Hello' 저장
// 3. 출력 시점
console.log(author1.name); // 'Hello' 출력
// 4. 추가 변경 및 명시적 flush
author1.name = 'Good';
await em.flush(); // 최종적으로 'Good'이 DB에 저장
TypeScript
복사
Identity Map 을 fork 한 경우
// 1. 초기 엔티티 로드
const author1 = await authorRepository.findOne(1); // 원본 Identity Map에 저장
author1.name = 'Wow'; // 변경 사항 추적 시작
// 2. fork()로 생성된 새 EntityManager에서 작업
em.fork().transactional(async (forkedEm) => {
const author = await forkedEm.findOne(Author, 1); // 분리된 Identity Map에서 새 인스턴스 로드
author.name = 'Hello'; // fork된 인스턴스만 수정
}); // 트랜잭션 종료 시 fork된 인스턴스의 변경사항('Hello') DB 반영
// 3. 출력 시점
console.log(author1.name); // 'Wow' 출력 → 원본 인스턴스 영향 없음
// 4. 원본 인스턴스 추가 수정
author1.name = 'Good';
await em.flush(); // 원본 인스턴스의 'Good'이 최종 저장
TypeScript
복사
Auto Flush (false 인 경우)
•
기본 동작
◦
PK 조회: Identity Map 의 엔티티 즉시 반환
◦
비-PK 조회: DB 쿼리 실행 → 결과를 Identity Map 과 병합
const author = await em.findOne(Author, 1); // PK 조회 → Identity Map 사용
author.name = 'Changed'; // 변경 사항 기록 (아직 flush 안 됨)
// 비-PK 조건으로 조회
const authors = await em.find(Author, { name: 'Changed' });
// 1. DB 쿼리 실행 (변경 전 데이터 조회)
// 2. Identity Map과 병합 → author 인스턴스의 변경 사항('Changed')이 우선 적용
// 3. flush 발생 ❌ (단순 병합만 수행)
TypeScript
복사
Auto Flush (true 인 경우)
const orm = await MikroORM.init({
autoFlush: true, // 모든 쿼리 전 자동 flush
});
// 변경 사항이 있을 경우 모든 조회 전 자동 flush
author.name = 'New Value';
const result = await em.find(Author, { name: 'New Value' }); // 자동 flush 발생 → 변경 사항 DB 반영
TypeScript
복사
FlushMode: Auto
Mikro-ORM v5에서 FlushMode.AUTO가 기본값으로 설정되어 있어, 컬렉션 조회 시 자동 flush가 발생하는 현상입니다. 이는 ORM이 쿼리 결과의 정합성을 보장하기 위해 설계된 동작입니다
•
FlushMode.AUTO 기본 동작
◦
v5에서는 FlushMode.AUTO가 기본값으로, 쿼리와 변경 사항이 겹칠 경우 자동 flush를 트리거합니다.
// 새로운 엔티티 생성 및 연결
const parent = new Parent();
parent.children.add(new Child()); // 변경 사항 기록 (flush 전)
// 컬렉션 조회 시 자동 flush 발생
const children = await em.find(Child, { parent }); // 자동 flush!
TypeScript
복사
•
쿼리와 변경 사항의 겹침(Overlap) 검출
◦
관계 필드 조회: parent.children 컬렉션을 조회하면 해당 관계와 연결된 모든 변경 사항이 검출됩니다
◦
Identity Map 동기화: 쿼리 실행 전 변경 사항이 DB에 반영되지 않으면 데이터 불일치 발생 가능성이 있으므로 ORM이 자동으로 flush를 수행합니다
•
상세 동작 메커니즘
// 1. 신규 엔티티 생성
const child = new Child();
parent.children.add(child); // Unit of Work에 변경 사항 등록
// 2. 컬렉션 조회 시 Mikro-ORM 내부 로직
await em.find(Child, { parent: parent.id });
/*
- 변경 사항 확인: parent.children에 새로운 child 존재
- Flush 필요성 판단: 쿼리 조건(parent.id)과 변경 사항(child.parent_id)이 겹침
- 자동 flush 실행: child INSERT 쿼리 발생
*/
TypeScript
복사
•
해결 방법
◦
Flush Mode 변경
// COMMIT 모드로 설정 (트랜잭션 커밋 시에만 flush)
em.setFlushMode(FlushMode.COMMIT);
TypeScript
복사
◦
명시적 트랜잭션 사용
await em.transactional(async () => {
parent.children.add(new Child());
// 트랜잭션 내 조회 시 flush 발생하지 않음
const children = await em.find(Child, { parent });
});
Plain Text
복사
◦
EntityManager 분리
const forkedEm = em.fork(); // 새로운 컨텍스트 생성
forkedEm.persist(parent);
parent.children.add(new Child());
// fork된 인스턴스에서 조회 (기존 변경 사항과 격리)
const children = await forkedEm.find(Child, { parent });
TypeScript
복사
◦
Cascade 설정 조정
@OneToMany(() => Child, child => child.parent, {
cascade: [], // Cascade.PERSIST 비활성화
})
children = new Collection<Child>(this);
TypeScript
복사
•
예방 패턴
// 1. Flush Mode 명시적 설정
const orm = await MikroORM.init({
flushMode: FlushMode.COMMIT, // 기본값 변경
});
// 2. 트랜잭션 범위 제한
await em.transactional(async () => {
// 작업 수행
}, { flushMode: FlushMode.COMMIT });
// 3. 배치 작업 시 수동 제어
parent.children.add(new Child());
await em.flush(); // 명시적 실행 후 조회
const children = await em.find(Child, { parent });
TypeScript
복사
em.create 옵션
구분 | managed: true | persist: true |
목적 | Identity Map 등록 (변경 추적) | DB 저장 예약 (INSERT) |
flush 영향 | 변경 감지 O | 변경 감지 + |
사용 시나리오 | 기존 엔티티 수정 추적 | 신규 엔티티 삽입 |
주의 | mikro-orm v5.5 이상부터는 global persistOnCreate 옵션을 따라가기 때문에 사용할 일이 거의 없음 |
•
managed: true ➔ 생성한 엔티티는 즉시 관리 상태가 되어 변경사항 추적이 가능하지만, 데이터베이스에 INSERT 예약은 하지 않으므로 신규 엔티티의 경우 별도로 em.persist() 호출이 필요합니다
•
persist: true ➔ 신규 엔티티 INSERT 예약
◦
persist: true 는 자동으로 managed 상태로 전환
결론
MikroORM은 Node.js와 TypeScript를 위한 강력한 ORM으로, 특히 Unit of Work와 Identity Map 패턴을 통해 엔티티 상태 관리와 데이터베이스 상호작용을 효율적으로 처리합니다.
MongoDB와의 통합에서도 트랜잭션 지원을 통해 데이터 일관성을 유지할 수 있습니다.
persist와 flush 메커니즘을 이해하고 적절히 활용하면 애플리케이션 성능을 최적화하고 복잡한 비즈니스 로직도 효과적으로 구현할 수 있습니다.
RequestContext와 AsyncLocalStorage를 통한 컨텍스트 관리는 웹 애플리케이션에서 요청별로 독립된 엔티티 관리를 가능하게 하며, 이를 통해 안정적이고 확장 가능한 시스템을 구축할 수 있습니다.
MongoDB 트랜잭션의 제약사항을 이해하고 적절히 대응함으로써, MikroORM과 MongoDB의 조합이 제공하는 모든 장점을 최대한 활용할 수 있을 것입니다.