NestJS + MikroORM Embeddables

Value Object가 필요한 이유: 원시 타입의 한계

주소(street, city, zip, country)를 User 엔티티에 직접 나열하면 컬럼이 늘어나고, 같은 구조를 Order 엔티티에도 반복해야 합니다. DDD(Domain-Driven Design)에서는 이런 구조를 Value Object로 묶습니다. MikroORM은 @Embeddable@Embedded 데코레이터로 Value Object 패턴을 프레임워크 수준에서 지원합니다. 공식 문서(Embeddables 섹션)에 따르면, Embeddable은 별도 테이블 없이 부모 엔티티의 컬럼으로 인라인됩니다.

기본 사용법: @Embeddable과 @Embedded

import { Embeddable, Embedded, Entity, PrimaryKey, Property } from '@mikro-orm/core';

@Embeddable()
export class Address {
  @Property()
  street!: string;

  @Property()
  city!: string;

  @Property()
  zip!: string;

  @Property()
  country!: string;
}

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

  @Property()
  name!: string;

  @Embedded(() => Address)
  address!: Address;
}

이 설정으로 users 테이블에는 address_street, address_city, address_zip, address_country 컬럼이 자동 생성됩니다. MikroORM은 기본적으로 {embedded_property}_{embeddable_property} 형식의 컬럼명을 사용합니다.

prefix 옵션으로 컬럼명 제어

공식 문서에 따르면 @Embedded()prefix 옵션을 지정하여 컬럼명 접두사를 변경하거나 제거할 수 있습니다.

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

  // 기본: address_street, address_city, ...
  @Embedded(() => Address)
  address!: Address;

  // 커스텀 접두사: billing_street, billing_city, ...
  @Embedded(() => Address, { prefix: 'billing_' })
  billingAddress!: Address;

  // 접두사 없음: street, city, zip, country
  @Embedded(() => Address, { prefix: false })
  mainAddress!: Address;
}

주의: prefix: false로 접두사를 제거하면, 같은 Embeddable 타입을 두 번 이상 사용할 때 컬럼명이 충돌합니다. 하나의 엔티티에 같은 Embeddable을 여러 번 사용한다면 반드시 서로 다른 prefix를 지정해야 합니다.

Nullable Embeddable: object 옵션

기본적으로 Embeddable의 모든 프로퍼티가 null이면 MikroORM은 Embeddable 자체를 null로 처리합니다. 이 동작은 object 옵션과 관계없이 기본 설정입니다. 공식 문서에서는 nullable: true를 프로퍼티에 설정하여 선택적 Embeddable을 만드는 방법을 설명합니다.

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

  @Embedded(() => Address)
  shippingAddress!: Address;

  // 청구 주소는 선택적 — 모든 컬럼이 NULL이면 null 반환
  @Embedded(() => Address, { prefix: 'billing_', nullable: true })
  billingAddress?: Address;
}

DB에서 billing_street, billing_city, billing_zip, billing_country가 모두 NULL이면, order.billingAddressnull이 됩니다. 일부만 NULL이면 Address 인스턴스가 생성되고 해당 필드만 undefined가 됩니다.

Nested Embeddables: Embeddable 안에 Embeddable

MikroORM v5+에서는 Embeddable 안에 다른 Embeddable을 중첩할 수 있습니다. 공식 문서에 이 기능이 명시되어 있습니다.

@Embeddable()
export class GeoPoint {
  @Property({ type: 'double' })
  latitude!: number;

  @Property({ type: 'double' })
  longitude!: number;
}

@Embeddable()
export class Address {
  @Property()
  street!: string;

  @Property()
  city!: string;

  @Property()
  zip!: string;

  @Embedded(() => GeoPoint)
  coordinates!: GeoPoint;
}

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

  @Embedded(() => Address)
  location!: Address;
}

이 경우 stores 테이블에는 location_street, location_city, location_zip, location_coordinates_latitude, location_coordinates_longitude 컬럼이 생성됩니다. prefix가 연쇄적으로 적용됩니다.

object 모드: JSON 컬럼으로 저장

MikroORM은 object: true 옵션으로 Embeddable을 개별 컬럼 대신 단일 JSON 컬럼으로 저장하는 모드를 지원합니다. 공식 문서의 “Storing Embeddables as Objects” 섹션에서 설명합니다.

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

  // 단일 JSON 컬럼 'address'에 저장
  @Embedded(() => Address, { object: true })
  address!: Address;
}

DB에는 address 컬럼 하나가 생성되고, {"street":"...","city":"...","zip":"...","country":"..."} 형태로 저장됩니다.

구분 인라인 모드 (기본) object 모드
DB 구조 Embeddable 프로퍼티별 개별 컬럼 단일 JSON 컬럼
인덱싱 ✅ 개별 컬럼에 인덱스 가능 ❌ JSON 내부 인덱싱은 DB 종속적
쿼리 ✅ WHERE 조건 직접 사용 ⚠️ JSON 경로 쿼리 필요
스키마 변경 Migration 필요 구조 변경 용이 (JSON이므로)
적합한 경우 자주 검색/필터링하는 데이터 읽기 전용, 구조가 자주 변경되는 데이터

Embeddable에서 쿼리하기

인라인 모드에서는 dot notation으로 Embeddable 내부 필드를 쿼리할 수 있습니다.

// EntityManager로 쿼리
const users = await em.find(User, {
  address: {
    city: 'Seoul',
    country: 'KR',
  },
});
// 생성되는 SQL: WHERE address_city = 'Seoul' AND address_country = 'KR'

// QueryBuilder에서도 동일
const result = await em.createQueryBuilder(User, 'u')
  .where({ 'u.address.city': 'Seoul' })
  .getResultList();

object 모드에서의 쿼리: object 모드는 JSON 컬럼이므로 DB별로 쿼리 방법이 다릅니다. PostgreSQL에서는 ->> 연산자, MySQL에서는 JSON_EXTRACT를 사용해야 합니다. MikroORM의 em.find()에서 object 모드 Embeddable의 내부 필드 필터링을 시도하면 예상대로 동작하지 않을 수 있으므로, 검색이 필요한 필드는 인라인 모드를 권장합니다.

NestJS 서비스 계층에서의 실무 패턴

import { Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import { InjectEntityManager } from '@mikro-orm/nestjs';

@Injectable()
export class UserService {
  constructor(
    @InjectEntityManager()
    private readonly em: EntityManager,
  ) {}

  async createUser(
    name: string,
    addressData: { street: string; city: string; zip: string; country: string },
  ) {
    // Embeddable은 일반 객체처럼 생성
    const address = new Address();
    Object.assign(address, addressData);

    const user = this.em.create(User, { name, address });
    await this.em.persistAndFlush(user);
    return user;
  }

  async updateAddress(userId: number, addressData: Partial<Address>) {
    const user = await this.em.findOneOrFail(User, userId);

    // assign으로 Embeddable 부분 업데이트
    this.em.assign(user, {
      address: { ...user.address, ...addressData },
    });

    await this.em.flush();
    return user;
  }

  async findByCity(city: string) {
    return this.em.find(User, {
      address: { city },
    });
  }
}

부분 업데이트 주의: em.assign()으로 Embeddable을 업데이트할 때, Embeddable 전체를 새 객체로 교체하면 변경 감지가 올바르게 작동합니다. 하지만 user.address.city = 'Busan'과 같이 직접 프로퍼티를 수정해도 MikroORM의 ChangeSet에 반영됩니다. 공식 문서에서는 em.assign()을 권장합니다.

Embeddable에 메서드 추가: 진정한 Value Object

Embeddable은 단순한 데이터 컨테이너가 아닙니다. 비즈니스 로직을 포함하는 메서드를 추가하여 진정한 Value Object로 만들 수 있습니다.

@Embeddable()
export class Money {
  @Property({ type: 'decimal', precision: 10, scale: 2 })
  amount!: string; // decimal은 string으로 안전하게 처리

  @Property({ length: 3 })
  currency!: string;

  /**
   * 같은 통화인지 확인 후 합산
   */
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error(
        `Currency mismatch: ${this.currency} vs ${other.currency}`,
      );
    }
    const result = new Money();
    result.amount = (
      parseFloat(this.amount) + parseFloat(other.amount)
    ).toFixed(2);
    result.currency = this.currency;
    return result;
  }

  /**
   * 표시용 문자열
   */
  format(): string {
    return `${this.currency} ${parseFloat(this.amount).toLocaleString()}`;
  }
}

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

  @Property()
  name!: string;

  @Embedded(() => Money)
  price!: Money;

  @Embedded(() => Money, { prefix: 'discount_' })
  discountPrice?: Money;
}

DB에는 price_amount, price_currency, discount_amount, discount_currency 컬럼이 생성됩니다. product.price.format()으로 비즈니스 로직을 호출할 수 있어 서비스 레이어의 부담이 줄어듭니다.

흔한 실수와 운영 체크리스트

실수 1: Embeddable에 @PrimaryKey 선언

Embeddable은 Identity가 없는 Value Object입니다. @PrimaryKey()를 선언하면 MikroORM이 에러를 발생시킵니다. ID가 필요하면 별도 엔티티로 분리해야 합니다.

실수 2: 관계(ManyToOne 등)를 Embeddable 안에 선언

MikroORM 공식 문서에 따르면 Embeddable 내부에는 관계(Association)를 선언할 수 없습니다. @ManyToOne()이나 @OneToMany()는 엔티티에서만 사용 가능합니다. 관계가 필요한 데이터는 Embeddable이 아닌 별도 엔티티로 설계해야 합니다.

실수 3: Embeddable 인스턴스를 공유

같은 Address 인스턴스를 두 엔티티에 할당하면 한쪽을 수정할 때 다른 쪽도 변경됩니다. Embeddable은 Value Object이므로 항상 새 인스턴스를 만들어야 합니다.

// ❌ 같은 인스턴스 공유 — 한쪽 수정 시 다른 쪽도 영향
const addr = new Address();
addr.city = 'Seoul';
user1.address = addr;
user2.address = addr; // 위험!

// ✅ 새 인스턴스 생성
user1.address = Object.assign(new Address(), { city: 'Seoul' });
user2.address = Object.assign(new Address(), { city: 'Seoul' });

실수 4: object 모드에서 Migration 누락

인라인 모드에서 object 모드로 변경하면 기존 개별 컬럼이 단일 JSON 컬럼으로 바뀝니다. Schema Generator가 기존 컬럼 삭제 + 새 컬럼 생성으로 처리하므로, 데이터가 유실됩니다. 모드 전환 시에는 반드시 수동 마이그레이션으로 데이터를 이전해야 합니다.

정리: Embeddable 사용 판단 기준

상황 권장 방식 이유
주소, 좌표, 금액 등 반복 구조 @Embeddable (인라인) 재사용, 개별 컬럼 인덱싱 가능
자주 변경되는 비정형 메타데이터 @Embeddable (object 모드) 스키마 변경 없이 구조 확장
관계(FK)가 필요한 데이터 별도 엔티티 Embeddable은 관계 미지원
독립 Identity가 필요한 데이터 별도 엔티티 Embeddable은 PK 없음
중첩 구조 (주소 + 좌표) Nested Embeddable prefix 자동 연쇄, 구조 명확

MikroORM의 Embeddable은 Value Object 패턴을 DB 컬럼에 자연스럽게 매핑합니다. 인라인 모드로 검색 가능한 개별 컬럼을, object 모드로 유연한 JSON 저장을 선택할 수 있습니다. NestJS에서는 em.create()em.assign()으로 Embeddable을 자연스럽게 생성·수정하며, 비즈니스 메서드를 Embeddable 클래스에 직접 구현하여 도메인 로직을 응집시킬 수 있습니다.

참고: MikroORM 공식 문서 — Embeddables, Defining Entities

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux