persist: false 패턴
MikroORM에서 @ManyToOne으로 관계를 정의하면 FK 컬럼에 해당하는 값은 관계 프로퍼티를 통해서만 접근할 수 있다. 예를 들어 Post 엔티티에 author라는 @ManyToOne 관계가 있으면, DB에는 author_id 컬럼이 존재하지만 코드에서는 post.author로만 접근한다.
@Entity()
export class Post {
@PrimaryKey()
id!: number;
@ManyToOne(() => User)
author!: User;
}
이 구조에서 author_id 값만 필요한 상황이 자주 발생한다. API 응답에 authorId를 포함해야 하거나, 특정 조건 분기에서 ID만 비교하면 되는 경우다. 그런데 post.author는 User 엔티티 참조이므로, 로드되지 않은 상태에서는 접근 자체가 번거롭다.
문제: FK 값에 직접 접근하기 어렵다
MikroORM의 관계 프로퍼티는 Reference wrapper나 엔티티 인스턴스를 반환한다. FK 값만 꺼내려면 몇 가지 방법이 있긴 하다.
방법 1: wrap().toObject()
const plain = wrap(post).toObject();
console.log(plain.author); // author_id 값 (숫자)
직렬화하면 FK 값이 나오지만, 매번 wrap을 호출해야 하고 전체 객체를 변환하는 것은 비효율적이다.
방법 2: populate로 로드
const post = await em.findOne(Post, id, { populate: ['author'] });
console.log(post.author.id); // User의 PK
FK ID만 필요한데 관계 엔티티 전체를 로드하는 것은 낭비다. 특히 author 테이블에 컬럼이 많거나, 대량 조회에서 N+1 쿼리가 발생할 수 있다.
방법 3: Reference wrapper 사용
@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 컬럼을 별도의 읽기 전용 프로퍼티로 노출할 수 있다.
@Entity()
export class Post {
@PrimaryKey()
id!: number;
@ManyToOne(() => User)
author!: User;
@Property({ persist: false })
authorId!: number;
}
이렇게 하면 MikroORM이 SELECT 쿼리를 실행할 때 author_id 컬럼 값을 authorId 프로퍼티에도 매핑해준다. DB 조회 결과에 author_id가 포함되어 있으므로 추가 쿼리 없이 바로 접근 가능하다.
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)에서 authorId는 author_id 컬럼에 매핑되는데, 이미 @ManyToOne이 해당 컬럼을 소유하고 있으므로 persist: false를 붙여서 쓰기를 방지하는 것이다.
만약 컬럼 이름이 자동 매핑과 다르면, fieldName을 명시할 수 있다.
@Property({ persist: false, fieldName: 'author_id' })
authorId!: number;
실전 활용 사례
API 응답에 FK ID 포함
가장 흔한 사용처다. 클라이언트에 엔티티를 직렬화할 때, 관계 객체 전체가 아닌 ID만 보내야 하는 경우가 많다.
@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의 컨트롤러에서 직접 반환하면 postId와 authorId가 숫자로 포함된다. 프론트엔드에서 필요한 형태 그대로다.
@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 값만으로 분기하는 경우.
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를 직접 참조하는 패턴이다.
@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 컬럼과 매핑되는 것이 아니라, 다른 프로퍼티를 조합해서 값을 계산한다.
@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에서도 사용 가능
@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 값을 변경하려면 반드시 관계 프로퍼티를 사용해야 한다.
// ❌ 동작하지 않음
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일 수 있다.
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가 된다.
// FK 컬럼이 SELECT에 포함되지 않으면 authorId도 없음
const post = await em.findOne(Post, 1, {
fields: ['id', 'title'], // author_id가 빠짐
});
console.log(post.authorId); // undefined
fields를 사용할 때는 미러 필드가 의존하는 FK도 함께 포함해야 한다.
const post = await em.findOne(Post, 1, {
fields: ['id', 'title', 'author'], // author(FK) 포함
});
console.log(post.authorId); // 42
QueryBuilder에서의 사용
QueryBuilder로 쿼리를 작성할 때 persist: false 프로퍼티 이름 대신 실제 관계 이름을 사용해야 한다.
// ❌ 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 값을 직접 넣는 것이 올바른 방식이다.
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 등) |
// Formula 예시: 댓글 수 계산
@Formula(alias => `(SELECT COUNT(*) FROM comment WHERE comment.post_id = ${alias}.id)`)
commentCount!: number;
FK 미러링 같은 단순 매핑은 persist: false, DB 레벨 계산이 필요한 경우는 @Formula가 적합하다.
정리
@Property({ persist: false })는 MikroORM에서 읽기 전용 프로퍼티를 정의하는 메커니즘이다. 주요 활용은 두 가지다.
-
FK 미러 필드: 관계 프로퍼티와 별도로 FK 값에 직접 접근할 수 있는 숫자/문자열 프로퍼티를 만든다. 추가 쿼리 없이 ID만 꺼낼 수 있어서 API 응답 구성이나 조건 비교에서 유용하다.
-
가상 프로퍼티: getter와 결합해서 다른 프로퍼티의 값을 조합한 계산 필드를 만든다. DB에 저장하지 않으면서 직렬화에는 포함할 수 있다.
핵심은 "읽기만 하고 쓰기는 하지 않는다"는 제약을 명시적으로 선언하는 것이다. 이를 통해 엔티티 인터페이스를 풍부하게 만들면서도, 실제 데이터 변경은 ORM의 관계 매핑을 통해서만 일어나도록 보장할 수 있다.