MikroORM Identity Map이란?
Identity Map은 하나의 요청(트랜잭션) 내에서 동일 엔티티를 한 번만 로드하고, 이후 조회 시 캐시된 인스턴스를 반환하는 패턴입니다. MikroORM은 Unit of Work와 함께 Identity Map을 핵심 아키텍처로 채택하여, 동일 엔티티에 대한 중복 쿼리를 방지하고 객체 동일성(reference equality)을 보장합니다.
JPA/Hibernate의 1차 캐시와 동일한 개념이지만, MikroORM은 Node.js 생태계에서 이를 구현한 거의 유일한 ORM입니다. TypeORM, Prisma, Drizzle 등은 Identity Map을 지원하지 않습니다.
Identity Map 동작 원리
MikroORM의 EntityManager(em)는 내부에 Identity Map을 유지합니다. 엔티티를 조회하면 먼저 Identity Map을 확인하고, 이미 로드된 엔티티라면 DB 쿼리 없이 캐시된 인스턴스를 반환합니다.
// 첫 번째 조회: DB 쿼리 실행
const user1 = await em.findOneOrFail(User, { id: 1 });
// SELECT * FROM "user" WHERE "id" = 1
// 두 번째 조회: Identity Map에서 반환 (쿼리 없음!)
const user2 = await em.findOneOrFail(User, { id: 1 });
// 객체 동일성 보장
console.log(user1 === user2); // true ✅
// 속성 변경이 자동으로 공유됨
user1.name = 'Updated';
console.log(user2.name); // 'Updated' — 같은 인스턴스이므로
RequestContext: 요청별 Identity Map 격리
Identity Map은 요청(request) 단위로 격리되어야 합니다. 하나의 글로벌 em을 공유하면 요청 간 엔티티가 섞여 심각한 버그가 발생합니다. MikroORM은 RequestContext 미들웨어로 요청별 em fork를 자동 관리합니다.
// NestJS에서 RequestContext 설정
import { MikroOrmModule } from '@mikro-orm/nestjs';
@Module({
imports: [
MikroOrmModule.forRoot({
// ... DB 설정
registerRequestContext: true, // 기본값: true
// NestJS 미들웨어로 자동 등록
}),
],
})
export class AppModule {}
// 수동 설정이 필요한 경우 (Express)
import { RequestContext } from '@mikro-orm/core';
app.use((req, res, next) => {
RequestContext.create(orm.em, next);
});
// 각 요청마다 독립된 em이 생성됨
@Injectable()
export class UserService {
constructor(
// 이 em은 RequestContext에서 fork된 인스턴스
private readonly em: EntityManager,
) {}
async findUser(id: number): Promise<User> {
// 이 요청 내에서만 유효한 Identity Map
return this.em.findOneOrFail(User, id);
}
}
Identity Map과 쿼리 최적화
Identity Map의 가장 큰 이점은 N+1 문제와 중복 쿼리를 자연스럽게 줄여준다는 점입니다.
// 시나리오: 주문 목록 조회 후 각 주문의 사용자 접근
const orders = await em.find(Order, {}, {
populate: ['user'], // JOIN으로 user 함께 로드
});
// Identity Map에 user들이 이미 로드됨
// 이후 같은 user를 다시 조회해도 쿼리 없음
for (const order of orders) {
// user가 Identity Map에 있으므로 추가 쿼리 없음
console.log(order.user.name);
}
// 다른 서비스에서 같은 user를 조회해도 캐시 히트
const user = await em.findOne(User, { id: orders[0].user.id });
// 쿼리 실행 안 됨 — Identity Map에서 반환
// populate 없이도 Identity Map 활용 가능
const orders2 = await em.find(Order, { status: 'pending' });
// user 관계가 이전 populate에서 이미 로드되었으면 자동 사용
em.clear()와 em.refresh()
Identity Map이 오래된 데이터를 들고 있을 때 수동으로 갱신하거나 초기화할 수 있습니다.
// Identity Map 전체 초기화
em.clear();
// 이후 모든 엔티티 조회 시 DB에서 새로 로드
// 특정 엔티티만 DB에서 새로 로드
const user = await em.findOneOrFail(User, 1);
// ... 외부에서 DB가 변경됨 ...
await em.refresh(user);
// user가 DB에서 최신 데이터로 갱신됨
// 특정 엔티티를 Identity Map에서 제거
em.getUnitOfWork().unsetIdentity(user);
// getReference: DB 조회 없이 Identity Map 또는 프록시 생성
const userRef = em.getReference(User, 1);
// Identity Map에 있으면 해당 인스턴스, 없으면 프록시 생성
// 관계 설정에 유용: FK만 필요하고 전체 조회는 불필요할 때
const order = em.create(Order, {
user: em.getReference(User, 1), // SELECT 없이 관계 설정
product: 'Widget',
});
Identity Map과 벌크 처리
대량 데이터를 처리할 때 Identity Map에 엔티티가 계속 쌓이면 메모리 사용량이 급증합니다. 벌크 작업에서는 주기적으로 em을 초기화해야 합니다.
// ❌ 안티패턴: 대량 조회 시 Identity Map 비대화
const allUsers = await em.find(User, {}); // 10만 건이면 10만 개 캐시
// ✅ 배치 처리: 주기적 flush + clear
const BATCH_SIZE = 500;
let offset = 0;
while (true) {
const users = await em.find(User, {}, {
limit: BATCH_SIZE,
offset,
disableIdentityMap: true, // v6: Identity Map 비활성화 옵션
});
if (users.length === 0) break;
for (const user of users) {
user.lastSyncedAt = new Date();
}
await em.flush();
em.clear(); // Identity Map 초기화 → 메모리 해제
offset += BATCH_SIZE;
}
// v6+: disableIdentityMap 옵션으로 특정 쿼리에서 IM 비활성화
const report = await em.find(Order, { status: 'completed' }, {
disableIdentityMap: true, // 읽기 전용 대량 조회
fields: ['id', 'total', 'createdAt'], // 필요한 필드만
});
Identity Map 디버깅
Identity Map 상태를 확인하여 성능 문제를 진단할 수 있습니다.
// Identity Map에 로드된 엔티티 수 확인
const uow = em.getUnitOfWork();
const identityMap = uow.getIdentityMap();
// 엔티티 타입별 카운트
for (const [className, map] of identityMap.entries()) {
console.log(`${className}: ${map.size} entities`);
}
// MikroORM 디버그 모드: 쿼리 실행 여부 확인
// mikro-orm.config.ts
export default defineConfig({
debug: ['query', 'query-params'],
// Identity Map 히트 시 쿼리 로그가 출력되지 않음
// → 로그가 없으면 Identity Map에서 반환된 것
// 로거 커스터마이징
logger: (message) => {
if (message.includes('identity map')) {
console.log('[IM HIT]', message);
}
},
});
Identity Map vs 다른 ORM 비교
| ORM | Identity Map | 객체 동일성 | 중복 쿼리 방지 |
|---|---|---|---|
| MikroORM | ✅ 내장 | ✅ 보장 | ✅ 자동 |
| TypeORM | ❌ | ❌ 매번 새 인스턴스 | ❌ 수동 캐싱 필요 |
| Prisma | ❌ | ❌ plain object | ❌ |
| Drizzle | ❌ | ❌ plain object | ❌ |
운영 베스트 프랙티스
- RequestContext 필수: 요청별 em 격리 없이는 Identity Map이 요청 간 오염됩니다
- 벌크 작업 시 clear(): 대량 처리에서는 배치마다
em.flush()+em.clear()로 메모리를 관리하세요 - getReference 활용: FK 설정만 필요할 때
em.getReference()로 불필요한 SELECT를 제거하세요 - 읽기 전용 대량 조회:
disableIdentityMap: true로 보고서/통계 쿼리의 메모리 부담을 줄이세요 - refresh로 최신화: 외부 변경이 예상되는 엔티티는
em.refresh()로 명시적으로 갱신하세요 - 디버그 로그 활용: 쿼리 로그가 없으면 Identity Map 히트 — 성능 최적화 확인에 유용합니다