junyeokk
Blog
MikroORM·2025. 11. 27

persist: false 패턴

MikroORM에서 @ManyToOne으로 관계를 정의하면 FK 컬럼에 해당하는 값은 관계 프로퍼티를 통해서만 접근할 수 있다. 예를 들어 Post 엔티티에 author라는 @ManyToOne 관계가 있으면, DB에는 author_id 컬럼이 존재하지만 코드에서는 post.author로만 접근한다.

typescript
@Entity()
export class Post {
  @PrimaryKey()
  id!: number;

  @ManyToOne(() => User)
  author!: User;
}

이 구조에서 author_id 값만 필요한 상황이 자주 발생한다. API 응답에 authorId를 포함해야 하거나, 특정 조건 분기에서 ID만 비교하면 되는 경우다. 그런데 post.authorUser 엔티티 참조이므로, 로드되지 않은 상태에서는 접근 자체가 번거롭다.


문제: FK 값에 직접 접근하기 어렵다

MikroORM의 관계 프로퍼티는 Reference wrapper나 엔티티 인스턴스를 반환한다. FK 값만 꺼내려면 몇 가지 방법이 있긴 하다.

방법 1: wrap().toObject()

typescript
const plain = wrap(post).toObject();
console.log(plain.author); // author_id 값 (숫자)

직렬화하면 FK 값이 나오지만, 매번 wrap을 호출해야 하고 전체 객체를 변환하는 것은 비효율적이다.

방법 2: populate로 로드

typescript
const post = await em.findOne(Post, id, { populate: ['author'] });
console.log(post.author.id); // User의 PK

FK ID만 필요한데 관계 엔티티 전체를 로드하는 것은 낭비다. 특히 author 테이블에 컬럼이 많거나, 대량 조회에서 N+1 쿼리가 발생할 수 있다.

방법 3: Reference wrapper 사용

typescript
@ManyToOne(() => User, { ref: true })
author!: Ref<User>;

// 접근
post.author.id; // populate 없이 FK 접근 가능

Ref 래퍼를 쓰면 populate 없이도 .id로 FK에 접근할 수 있다. 하지만 이 방식은 관계 프로퍼티의 타입이 Ref<User>로 바뀌므로, 실제 엔티티 프로퍼티에 접근하려면 .unwrap()이나 .load()가 필요해진다. 프로젝트 전체의 관계 접근 패턴이 달라지는 트레이드오프가 있다.


persist: false로 FK 미러 필드 만들기

@Property({ persist: false })는 "이 프로퍼티는 DB에서 읽어오지만, flush 시 INSERT/UPDATE에 포함하지 않는다"는 의미다. 이걸 이용해서 FK 컬럼을 별도의 읽기 전용 프로퍼티로 노출할 수 있다.

typescript
@Entity()
export class Post {
  @PrimaryKey()
  id!: number;

  @ManyToOne(() => User)
  author!: User;

  @Property({ persist: false })
  authorId!: number;
}

이렇게 하면 MikroORM이 SELECT 쿼리를 실행할 때 author_id 컬럼 값을 authorId 프로퍼티에도 매핑해준다. DB 조회 결과에 author_id가 포함되어 있으므로 추가 쿼리 없이 바로 접근 가능하다.

typescript
const post = await em.findOne(Post, 1);
console.log(post.authorId); // 42 — 추가 쿼리 없음
console.log(post.author);   // Ref or unloaded entity

persist: false이기 때문에 em.flush() 시 이 프로퍼티는 무시된다. 실제 FK 값 변경은 반드시 관계 프로퍼티(author)를 통해서만 해야 한다.


동작 원리

MikroORM이 엔티티를 하이드레이션(hydration)할 때, DB 결과 행의 컬럼을 엔티티 프로퍼티에 매핑하는 과정을 거친다. @ManyToOne(() => User) author가 정의되어 있으면 내부적으로 author_id라는 FK 컬럼이 생성된다.

@Property({ persist: false }) authorId를 추가하면, MikroORM의 메타데이터 시스템은 이 프로퍼티도 author_id 컬럼에서 값을 읽어와야 한다는 것을 인식한다. 왜냐하면 authorId라는 이름이 author + Id 패턴과 일치하기 때문이다.

정확히는 MikroORM이 네이밍 전략을 기반으로 매핑한다. 기본 네이밍 전략(UnderscoreNamingStrategy)에서 authorIdauthor_id 컬럼에 매핑되는데, 이미 @ManyToOne이 해당 컬럼을 소유하고 있으므로 persist: false를 붙여서 쓰기를 방지하는 것이다.

만약 컬럼 이름이 자동 매핑과 다르면, fieldName을 명시할 수 있다.

typescript
@Property({ persist: false, fieldName: 'author_id' })
authorId!: number;

실전 활용 사례

API 응답에 FK ID 포함

가장 흔한 사용처다. 클라이언트에 엔티티를 직렬화할 때, 관계 객체 전체가 아닌 ID만 보내야 하는 경우가 많다.

typescript
@Entity()
export class Comment {
  @PrimaryKey()
  id!: number;

  @Property()
  content!: string;

  @ManyToOne(() => Post)
  post!: Post;

  @Property({ persist: false })
  postId!: number;

  @ManyToOne(() => User)
  author!: User;

  @Property({ persist: false })
  authorId!: number;
}

NestJS의 컨트롤러에서 직접 반환하면 postIdauthorId가 숫자로 포함된다. 프론트엔드에서 필요한 형태 그대로다.

typescript
@Get(':id')
async findOne(@Param('id') id: number) {
  const comment = await this.em.findOne(Comment, id);
  return {
    id: comment.id,
    content: comment.content,
    postId: comment.postId,    // 추가 쿼리 없이 바로 접근
    authorId: comment.authorId,
  };
}

조건부 로직에서 ID 비교

관계 엔티티를 로드하지 않고 FK 값만으로 분기하는 경우.

typescript
async canEdit(commentId: number, userId: number): Promise<boolean> {
  const comment = await this.em.findOne(Comment, commentId);
  // populate 없이 비교 가능
  return comment.authorId === userId;
}

populate: ['author']를 쓰면 User 테이블에 JOIN이나 추가 SELECT가 발생하지만, authorId를 쓰면 Comment 테이블만 조회하면 된다.

부모-자식 관계에서 parentId

트리 구조에서 부모 ID를 직접 참조하는 패턴이다.

typescript
@Entity()
export class Category {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @ManyToOne(() => Category, { nullable: true })
  parent?: Category;

  @Property({ persist: false })
  parentId?: number | null;

  @OneToMany(() => Category, c => c.parent)
  children = new Collection<Category>(this);
}

카테고리 목록을 조회할 때 parentId로 트리를 클라이언트에서 조립할 수 있다. 서버에서 전체 트리를 재귀적으로 populate할 필요가 없다.


getter로 가상 프로퍼티 만들기

persist: false는 getter와 결합해서 가상(computed) 프로퍼티를 만드는 데도 쓰인다. 이 경우 DB 컬럼과 매핑되는 것이 아니라, 다른 프로퍼티를 조합해서 값을 계산한다.

typescript
@Entity()
export class User {
  @PrimaryKey()
  id!: number;

  @Property()
  firstName!: string;

  @Property()
  lastName!: string;

  @Property({ persist: false })
  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }
}

fullName은 DB에 컬럼이 없지만, toObject()나 직렬화 시 포함된다. persist: false가 없으면 MikroORM이 이 값을 DB에 저장하려고 시도한다.

Embeddable에서도 사용 가능

typescript
@Embeddable()
export class Address {
  @Property({ hidden: true })
  addressLine1!: string;

  @Property({ hidden: true })
  addressLine2!: string;

  @Property({ persist: false })
  get fullAddress(): string {
    return [this.addressLine1, this.addressLine2].filter(Boolean).join(' ');
  }

  @Property()
  city!: string;
}

hidden: true로 원본 필드를 숨기고, persist: false getter로 가공된 값만 노출하는 패턴이다.


주의사항

쓰기는 관계 프로퍼티로

persist: false 필드에 값을 할당해도 DB에 반영되지 않는다. FK 값을 변경하려면 반드시 관계 프로퍼티를 사용해야 한다.

typescript
// ❌ 동작하지 않음
comment.authorId = 99;
await em.flush(); // author_id는 변경되지 않음

// ✅ 올바른 방법
comment.author = em.getReference(User, 99);
await em.flush(); // author_id가 99로 변경됨

em.getReference()는 실제 DB 조회 없이 프록시를 생성하므로, FK 변경만을 위해 엔티티를 로드할 필요가 없다.

하이드레이션 시점에만 값이 채워진다

persist: false 프로퍼티는 DB에서 읽어올 때만 값이 설정된다. 엔티티를 새로 생성하고 persist/flush한 직후에는 이 값이 undefined일 수 있다.

typescript
const post = new Post();
post.author = em.getReference(User, 42);
post.title = 'Hello';

em.persist(post);
await em.flush();

console.log(post.authorId); // undefined일 수 있음!

// 다시 조회하면 값이 채워짐
await em.refresh(post);
console.log(post.authorId); // 42

새로 생성한 엔티티에서 FK ID가 바로 필요하다면, em.refresh()로 다시 로드하거나, 관계 프로퍼티에서 직접 꺼내야 한다.

fields 옵션과 함께 사용

em.find()에서 fields 옵션으로 특정 컬럼만 선택할 때, FK 컬럼을 포함하지 않으면 persist: false 프로퍼티도 undefined가 된다.

typescript
// FK 컬럼이 SELECT에 포함되지 않으면 authorId도 없음
const post = await em.findOne(Post, 1, {
  fields: ['id', 'title'], // author_id가 빠짐
});
console.log(post.authorId); // undefined

fields를 사용할 때는 미러 필드가 의존하는 FK도 함께 포함해야 한다.

typescript
const post = await em.findOne(Post, 1, {
  fields: ['id', 'title', 'author'], // author(FK) 포함
});
console.log(post.authorId); // 42

QueryBuilder에서의 사용

QueryBuilder로 쿼리를 작성할 때 persist: false 프로퍼티 이름 대신 실제 관계 이름을 사용해야 한다.

typescript
// ❌ persist: false 프로퍼티 이름으로는 조건 불가
const posts = await em.createQueryBuilder(Post)
  .where({ authorId: 42 }) // 에러 또는 무시될 수 있음
  .getResult();

// ✅ 관계 이름 사용
const posts = await em.createQueryBuilder(Post)
  .where({ author: 42 }) // 내부적으로 author_id = 42
  .getResult();

em.find()where 조건에서도 마찬가지로, 관계 이름에 ID 값을 직접 넣는 것이 올바른 방식이다.

typescript
const posts = await em.find(Post, { author: 42 });

persist: false vs Formula

비슷한 역할을 하는 @Formula 데코레이터와 비교해보자.

특성persist: false@Formula
DB 컬럼 필요O (기존 컬럼 활용)X (SQL 표현식)
SQL 실행SELECT 시 컬럼 매핑SELECT 시 서브쿼리/표현식
쓰기 가능아니오아니오
JS 로직 사용O (getter)X (SQL만)
집계/계산X (단순 매핑)O (COUNT, SUM 등)
typescript
// Formula 예시: 댓글 수 계산
@Formula(alias => `(SELECT COUNT(*) FROM comment WHERE comment.post_id = ${alias}.id)`)
commentCount!: number;

FK 미러링 같은 단순 매핑은 persist: false, DB 레벨 계산이 필요한 경우는 @Formula가 적합하다.


정리

@Property({ persist: false })는 MikroORM에서 읽기 전용 프로퍼티를 정의하는 메커니즘이다. 주요 활용은 두 가지다.

  1. FK 미러 필드: 관계 프로퍼티와 별도로 FK 값에 직접 접근할 수 있는 숫자/문자열 프로퍼티를 만든다. 추가 쿼리 없이 ID만 꺼낼 수 있어서 API 응답 구성이나 조건 비교에서 유용하다.

  2. 가상 프로퍼티: getter와 결합해서 다른 프로퍼티의 값을 조합한 계산 필드를 만든다. DB에 저장하지 않으면서 직렬화에는 포함할 수 있다.

핵심은 "읽기만 하고 쓰기는 하지 않는다"는 제약을 명시적으로 선언하는 것이다. 이를 통해 엔티티 인터페이스를 풍부하게 만들면서도, 실제 데이터 변경은 ORM의 관계 매핑을 통해서만 일어나도록 보장할 수 있다.

관련 문서