MikroORM 과 Entity Cache

MikroORM 사용 시 Entity Cache로 인해 발생할 수 있는 문제점과 해결 방법을 상세히 안내해 드립니다.

Contents


MikroORM

mikro-orm.png

  사내의 Node.js 서버에서는 ORM 라이브러리로 TypeORM 과 MikroORM 을 사용하고 있습니다. 개인적으로는 두 ORM 라이브러리 중 MikroORM 을 더 선호합니다. 사내에는 Spring 서버로 작업을 해야 할 때에도 있는데, MikroORM 의 동작 방식이 Spring 서버에서 사용하고 있는 Hibernate 의 동작 방식과 유사하기 때문입니다. EntityManager, Transaction Context, Entith Cache 등의 동작 방식은 Hibernate 의 그것과 유사합니다.

  단, 라이브러리의 완성도는 Hibernate 가 훨씬 높다고 생각합니다. 이 글에서 소개하려는 이슈 또한 Hibernate 와 닮으면서도, 동작이 살짝 다른 이슈입니다. MikroORM 을 사용하면서 발견한 Entity Cache 이슈에 대해 정리해 보려고 합니다.


Entity Cache

  Hibernate 문서에서는 Persistence Context에 대해 상세히 기술하고 있습니다.

persistence-context.png

A persistence context is a sort of cache; we sometimes call it the "first-level cache", to distinguish it from the second-level cache. For every entity instance read from the database within the scope of a persistence context, and for every new entity made persistent within the scope of the persistence context, the context holds a unique mapping from the identifier of the entity instance to the instance itself.


Persistence Context 에서 관리되는 Entity Instance 들은 각자의 고유 식별자로 캐싱됩니다. 이러한 캐싱 기능을 통해, DB에 대한 쿼리를 줄일 수 있습니다. 하지만 캐시가 사용되지 않을 때 캐시를 사용하게 되면, 문제가 발생하기도 합니다. 이 글에서는 MikroORM 에서 Entity Cache 로 인해 발생하는 문제점에 대해 설명합니다.


문제 재현

  문제를 재현하기 위해, 간단하게 프로젝트를 설정해 보겠습니다. 먼저, 다음과 같이 package.json 을 설정합니다.

{
  "name": "mikroorm-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "ts-node src/index.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@mikro-orm/better-sqlite": "^5",
    "@mikro-orm/core": "^5"
  },
  "devDependencies": {
    "prettier": "^3.5.1",
    "ts-node": "^10.9.2",
    "typescript": "^5.7.3"
  }
}

예제 코드에서는 Course, Teacher 라는 두 개의 Entity 만 설정해 보겠습니다.

/**
  * src/entity/Course.ts
  */
import {
  Entity,
  ManyToOne,
  PrimaryKey,
  Property,
  Ref,
  wrap,
} from "@mikro-orm/core";
import { Teacher } from "./Teacher";

@Entity({ tableName: "courses" })
export class Course {
  @PrimaryKey()
  id: number;

  @Property({ comment: "강의명" })
  title: string;

  @ManyToOne(() => Teacher, {
    joinColumn: "teacher_id",
    ref: true,
  })
  teacher: Ref<Teacher>;

  constructor(title: string, teacher: Teacher) {
    this.title = title;
    this.teacher = wrap(teacher).toReference();
  }

  getTeacherName(): string {
    return this.teacher.unwrap().name;
  }
}

/**
 * src/entity/Teacher.ts
 */
import { Entity, PrimaryKey, Property } from "@mikro-orm/core";

@Entity({ tableName: "teachers" })
export class Teacher {
  @PrimaryKey()
  id: number;

  @Property({ comment: "선생님명" })
  name: string;

  @Property({ comment: "선생님의 나이" })
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

그리고 DB 에 연결하고 테스트용 seed 데이터를 입력하는 간단한 함수를 작성해 보겠습니다.

/**
  * DB 와 연결 후, 생성된 EntityManager로 함수를 실행해 준다. 
  */
async function withEntityManager(fn: (orm: EntityManager) => Promise<void>) {
  const orm = await MikroORM.init<BetterSqliteDriver>({
    entities: [Course, Teacher],
    dbName: "school",
    driver: BetterSqliteDriver,
    debug: true,
  });

  try {
    await orm.getSchemaGenerator().dropSchema();
    await orm.getSchemaGenerator().updateSchema();
    await orm.getSchemaGenerator().refreshDatabase();

    await fn(orm.em.fork());
  } finally {
    await orm.close();
  }
}

/**
  * seed 데이터를 생성한다.
  */
async function seed(em: EntityManager) {
  for (let i = 0; i < 10; i++) {
    const teacherName = `teacher${i}`;
    const teacher = new Teacher(teacherName, 50);

    const course1 = new Course(`course-${teacherName}-1`, teacher);
    const course2 = new Course(`course-${teacherName}-2`, teacher);

    em.persist([teacher, course1, course2]);
  }

  // 시드 데이터 저장
  await em.flush();

  // 앤티티 캐시를 초기화한다
  em.clear();
}

이제 테스트에 필요한 환경은 구성을 완료했으니, 실질적인 테스트 코드를 작성해 보겠습니다.

async function main() {
  await withEntityManager(async (em) => {
    await seed(em);

    /** 1번 실행문 */
    await em.findOne(
      Teacher,
      { name: "teacher1" },
      {
        fields: ["id", "age"],
      },
    );

    /** 2번 실행문 */
    const course = await em.findOne(
      Course,
      { title: "course-teacher1-1" },
      {
        populate: ["teacher"],
      },
    );

    console.log(
      `courseTitle : ${course?.title}, teacherName : ${course?.getTeacherName()}`,
    );
  });
}

이 코드를 발생하면 콘솔에는 어떠한 결과가 출력될까요 ?


신기하게도, MikroORM 5버전과 MikroORM 6버전의 출력 결과는 다릅니다. 버전 간 차이는 뒷부분에서 설명하고, 일단은 5버전 기준으로 설명을 이어가 보겠습니다.

[Version 5] : courseTitle : course-teacher1-1, teacherName : undefined
[Version 6] : courseTitle : course-teacher1-1, teacherName : teacher1

이 글의 앞부분에서 설명드린 대로, Entity Cache 가 이러한 현상을 만들게 되었습니다. 위의 스크립트를 실행할 때 실행되는 SQL 을 살펴보겠습니다.

/** 1번 실행문 */
[query] select `t0`.`id`, `t0`.`age` from `teachers` as `t0` where `t0`.`name` = 'teacher1' limit 1 [took 1 ms, 1 result]

/** 2번 실행문 */
[query] select `c0`.* from `courses` as `c0` where `c0`.`title` = 'course-teacher1-1' limit 1 [took 0 ms, 1 result]

[1번 실행문]이 실행될 때 얻은 Teacher Entity 를 [2번 실행문]의 course.getTeacherName() 이 실행될 때 재활용한다는 사실을 알 수 있었습니다. 하지만 [1번 실행문]에서는 teacher.name 을 projection 하지 않기 때문에, undefined 가 출력되는 것이죠.


EntityManager 가 더 똑똑했다면, [2번 실행문]이 실행될 때 teacher 엔티티를 재활용하면 안된다는 사실을 알았겠지만, EntityManager 은 ID 식별자 기준으로 캐싱 여부를 확인합니다. 따라서 [2번 실행문]에서는 캐싱된 Teacher 엔티티를 사용하게 됩니다.

그렇다면 MikroORM 6 버전에서는 왜 이런 현상이 발생하지 않을까요 ? 답은 Version 6 에서 변경된 Loading Strategy 에 있습니다. MikroORM 6 버전에서는 LoadStrategy.JOINED 가 기본값으로 설정되어 있습니다. (관련 문서)


loading-strategy.png

이 설정은 JOIN 쿼리를 사용하여 Entity 를 로딩합니다. 따라서, [2번 실행문]에서 teacher 엔티티를 로딩할 때, JOIN 쿼리를 사용하여 teacher 엔티티를 로딩합니다. 이 때, teacher 엔티티를 캐싱하지 않기 때문에, teacher 엔티티를 캐싱하지 않습니다. 따라서, teacher 엔티티를 캐싱하지 않습니다.

package.json 에서 MikroORM 의 버전을 6으로 변경한 후 스크립트를 실행하면, 다음과 같은 SQL 이 실행됩니다.

/** 1번 실행문 */
[query] select `t0`.`id`, `t0`.`age` from `teachers` as `t0` where `t0`.`name` = 'teacher1' limit 1 [took 0 ms, 1 result]

/** 2번 실행문 */
[query] select `c0`.*, `t1`.`id` as `t1__id`, `t1`.`name` as `t1__name`, `t1`.`age` as `t1__age` from `courses` as `c0` left join `teachers` as `t1` on `c0`.`teacher_id` = `t1`.`id` where `c0`.`title` = 'course-teacher1-1' limit 1 [took 0 ms, 1 result]

즉, [2번 실행문] 을 실행할 때에 항상 JOIN 쿼리를 사용하여 teacher 엔티티를 실시간으로 로딩하는 것을 알 수 있습니다. 만약 LoadingStrategy 를 다시 SELECT_IN 으로 수정하면, 다시 앤티티 캐시가 사용됨을 알 수 있습니다.

const course = await em.findOne(
  Course,
  { title: "course-teacher1-1" },
  {
    populate: ["teacher"],
    /** JOINED Strategy 를 사용하면 엔티티 캐시 문제 발생 안함 */
    // strategy: LoadStrategy.JOINED,

    /** SELECT_IN Strategy 를 사용하면 엔티티 캐시 문제 발생함 */
    strategy: LoadStrategy.SELECT_IN,
  },
);

그렇다면, 이러한 경우에 Entity Cache 를 사용하지 않게 하려면 어떻게 해야 할까요 ?


해결 방법

1. LoadingStrategy.JOINED

만약 Entity Cache 로 인한 문제가 발생할 것 같다면, LoadingStrategy.JOINED 를 사용하는 것이 간단한 해결 방법입니다. SELECT + JOIN 쿼리를 사용하면, SQL 단에서 항상 데이터를 로드하기 때문에, 캐싱을 사용하지 않게 됩니다.


2. EntityManager.clear()

EntityManager.clear() 를 사용하면, 현재 EntityManager 에 설정되어 있는 캐시 값들을 초기화할 수 있습니다.

await em.findOne(
  Teacher,
  { name: "teacher1" },
  {
    fields: ["id", "age"],
  },
);

// 캐시 초기화
em.clear();

const course = await em.findOne(
  Course,
  { title: "course-teacher1-1" },
  {
    populate: ["teacher"],
  },
);


3. refresh 옵션 추가

EntityManager.find() 함수를 사용할 때, refresh 옵션을 추가하면, 캐시를 사용하지 않게 됩니다.

await em.findOne(
  Teacher,
  { name: "teacher1" },
  {
    fields: ["id", "age"],
  },
);

const course = await em.findOne(
  Course,
  { title: "course-teacher1-1" },
  {
    populate: ["teacher"],
    // 캐시를 사용하지 않음
    refresh: true,
  },
);


Hibernate 와의 비교

  Hibernate 에서는 이러한 현상이 발생하지 않을까요 ? Hibernate 에서도 Entity Cache 가 존재하지만, MikroORM 과의 근본적인 차이점이 있습니다. Hibernate 에서는 EntityManager 를 통해 Entity 를 로드할 때, 일부 컬럼만 로드하는 기능을 제한하고 있습니다. JPQL 을 통해서는 이와 같은 기능을 사용할 수 있지만, 이와 같이 로드된 데이터는 캐싱되지 않습니다. 즉, Hibernate 에서는 이러한 캐싱 문제가 발생하는 일을 원천적으로 차단한 것입니다.


정리

  이 글에서는 MikroORM 에서 발생할 수 있는 Entity Cache 문제에 대해 살펴보았습니다. 어플리케이션의 성능을 위해 테이블의 특정 컬럼만 projection 하는 것을 일반적인 쿼리입니다. 따라서 MikroORM 을 사용할 때에 위와 같은 예상치 못한 캐시 문제를 유념하여 사용해야 합니다.


이것도 읽어보세요