junyeokk
Blog
MikroORM·2025. 11. 27

MikroORM JSON 컬럼

관계형 데이터베이스는 정해진 스키마가 장점이지만, 모든 데이터가 고정된 컬럼 구조에 딱 맞아떨어지는 건 아니다. 사용자별 설정값, 메타데이터, 다국어 텍스트처럼 구조가 유동적인 데이터를 저장해야 할 때가 있다. 이런 데이터를 정규화하면 테이블이 불필요하게 늘어나고, EAV(Entity-Attribute-Value) 패턴을 쓰면 쿼리가 복잡해진다.

PostgreSQL의 jsonb 타입은 이 문제를 해결한다. JSON 데이터를 바이너리로 저장하면서도 내부 필드에 인덱스를 걸고 쿼리할 수 있다. MikroORM에서는 type: 'json'으로 간단하게 JSON 컬럼을 정의하고, TypeScript 타입 안전성까지 유지할 수 있다.


JSON vs JSONB

PostgreSQL에는 jsonjsonb 두 가지 JSON 타입이 있다. 차이를 알아야 올바른 선택을 할 수 있다.

json 타입은 입력된 텍스트를 그대로 저장한다. 저장할 때 파싱 비용이 없지만, 읽을 때마다 파싱해야 한다. 키 순서와 중복 키가 보존되고, 인덱스를 걸 수 없다.

jsonb 타입은 바이너리 형태로 분해해서 저장한다. 저장 시 파싱 비용이 있지만, 읽기가 빠르다. 키 순서가 보장되지 않고 중복 키는 마지막 값만 유지된다. 가장 중요한 차이는 GIN 인덱스를 걸 수 있다는 점이다.

text
json  → 텍스트 그대로 저장, 인덱스 불가, 쓰기 빠름
jsonb → 바이너리 저장, GIN 인덱스 가능, 읽기/쿼리 빠름

실무에서는 거의 항상 jsonb를 쓴다. MikroORM에서 type: 'json'으로 정의하면 PostgreSQL에서는 자동으로 jsonb 컬럼이 생성된다.


기본 정의

MikroORM에서 JSON 컬럼을 정의하는 방법은 간단하다.

typescript
@Entity()
export class User {

  @Property({ type: 'json', nullable: true })
  settings?: UserSettings;

}

type: 'json'을 지정하면 MikroORM이 내부적으로 JsonType을 사용한다. 이 커스텀 타입이 드라이버별 차이를 통일해준다. 어떤 드라이버는 JSON을 자동 파싱해서 객체로 반환하고, 어떤 드라이버는 문자열 그대로 반환하는데, JsonType이 이 차이를 흡수해서 항상 파싱된 객체를 돌려준다.

TypeScript 인터페이스로 타입 정의

JSON 컬럼의 구조를 인터페이스로 정의하면 타입 안전성을 확보할 수 있다.

typescript
interface UserSettings {
  theme: 'light' | 'dark';
  language: string;
  notifications: {
    email: boolean;
    push: boolean;
    frequency: 'immediate' | 'daily' | 'weekly';
  };
}

@Entity()
export class User {

  @PrimaryKey()
  id!: number;

  @Property({ type: 'json' })
  settings: UserSettings = {
    theme: 'light',
    language: 'ko',
    notifications: {
      email: true,
      push: true,
      frequency: 'immediate',
    },
  };

}

기본값을 프로퍼티 초기화로 설정하면 INSERT 시 자동으로 기본 설정이 들어간다. 다만 이건 런타임 타입 체크일 뿐이고, 데이터베이스 레벨에서 스키마 검증은 하지 않는다. 외부에서 직접 DB에 값을 넣으면 타입이 깨질 수 있다는 점을 인지해야 한다.

nullable과 기본값 전략

JSON 컬럼을 nullable로 할지, 빈 객체를 기본값으로 할지는 의미가 다르다.

typescript
// "설정이 없음" (null)과 "빈 설정" ({})은 다른 의미
@Property({ type: 'json', nullable: true })
metadata?: Record<string, unknown>;

// 항상 객체가 존재하되, 비어 있을 수 있음
@Property({ type: 'json' })
metadata: Record<string, unknown> = {};

"아직 설정한 적 없음"과 "설정했는데 비어 있음"을 구분해야 한다면 nullable을 쓰고, 그 구분이 필요 없다면 빈 객체 기본값이 더 편하다. 빈 객체 기본값을 쓰면 user.metadata.someKey 접근 시 null 체크를 생략할 수 있다.


JSON 속성으로 쿼리

MikroORM에서는 JSON 내부 속성을 일반 필드처럼 쿼리할 수 있다. 중첩된 속성도 객체 구조를 그대로 사용하면 된다.

typescript
// 단일 속성 쿼리
const darkUsers = await em.find(User, {
  settings: {
    theme: 'dark',
  },
});

// 중첩 속성 쿼리
const emailUsers = await em.find(User, {
  settings: {
    notifications: {
      email: true,
      frequency: 'immediate',
    },
  },
});

PostgreSQL에서 이 쿼리는 다음과 같이 변환된다.

sql
select "u0".*
from "user" as "u0"
where ("settings"->>'theme') = 'dark'

MikroORM이 값의 타입을 감지해서 적절한 캐스팅을 자동으로 추가한다. boolean이면 ::bool, 숫자면 ::float8으로 캐스팅한다.

sql
-- boolean 값 쿼리
where ("settings"->'notifications'->>'email')::bool = true

-- 숫자 값 쿼리  
where ("settings"->>'maxRetries')::float8 = 3

이 기능은 PostgreSQL, MySQL, SQLite, MongoDB 모두에서 동작한다. 드라이버마다 내부적으로 생성하는 SQL은 다르지만, MikroORM API는 동일하다.

주의: 부분 일치 vs 정확 일치

JSON 쿼리는 지정한 키만 비교하는 부분 일치다. { theme: 'dark' }로 쿼리하면 settings 객체에 다른 키가 뭐가 있든 theme'dark'인 레코드를 모두 반환한다. JSON 객체 전체가 정확히 일치하는지 비교하는 게 아니다.


인덱스

JSON 컬럼 자체에 B-tree 인덱스를 거는 건 의미가 없다. JSON 내부의 특정 필드를 자주 쿼리한다면 그 필드에 인덱스를 걸어야 한다. MikroORM에서는 엔티티 레벨 @Index() 데코레이터에 dot path를 사용한다.

typescript
@Entity()
@Index({ properties: 'settings.theme' })
@Index({ properties: ['settings.language', 'settings.theme'] }) // 복합 인덱스
export class User {

  @Property({ type: 'json', nullable: true })
  settings?: UserSettings;

}

이 코드는 PostgreSQL에서 다음 DDL을 생성한다.

sql
create index "user_settings_theme_index"
  on "user" (("settings"->>'theme'));

->> 연산자는 JSON 값을 텍스트로 추출한다. 즉 이 인덱스는 settings.theme의 텍스트 값에 대한 B-tree 인덱스다.

GIN 인덱스

특정 필드가 아니라 JSON 객체 전체를 대상으로 다양한 쿼리를 해야 한다면 GIN 인덱스가 적합하다. GIN(Generalized Inverted Index)은 JSON의 모든 키-값 쌍을 인덱싱해서 @> (contains) 연산자를 빠르게 처리한다.

MikroORM에서 GIN 인덱스를 직접 정의하는 데코레이터 옵션은 없으므로 마이그레이션에서 직접 SQL을 작성한다.

sql
CREATE INDEX "user_settings_gin" ON "user" USING GIN ("settings");

GIN 인덱스는 쓰기 성능을 약간 희생하는 대신 다양한 JSON 쿼리를 빠르게 처리한다. 어떤 키로 검색할지 미리 알 수 없는 메타데이터 같은 경우에 유용하다.

유니크 인덱스

JSON 속성에 유니크 제약 조건도 걸 수 있다.

typescript
@Entity()
@Unique({ properties: 'profile.email' })
export class User {

  @Property({ type: 'json' })
  profile!: { email: string; name: string };

}

변경 감지

MikroORM의 Unit of Work는 엔티티의 변경 사항을 자동으로 감지해서 flush() 시 UPDATE 쿼리를 생성한다. JSON 컬럼도 마찬가지인데, 동작 방식을 이해해야 의도치 않은 문제를 피할 수 있다.

typescript
const user = await em.findOneOrFail(User, 1);
user.settings.theme = 'dark';
await em.flush(); // UPDATE 실행됨

MikroORM은 엔티티를 로드할 때 JSON 값의 스냅샷을 저장해둔다. flush() 시점에 현재 값과 스냅샷을 깊은 비교(deep comparison)해서 변경 여부를 판단한다. 중첩된 속성을 변경해도 정상적으로 감지된다.

typescript
// 이것도 감지됨
user.settings.notifications.push = false;
await em.flush();

객체 전체 교체 vs 속성 수정

둘 다 정상 동작한다. 하지만 성능 관점에서는 객체 전체를 교체하든 속성만 수정하든 어차피 JSON 컬럼 전체가 UPDATE된다. 관계형 DB에서 JSON 컬럼의 부분 업데이트는 불가능하기 때문이다.

typescript
// 속성만 수정 → settings 전체가 UPDATE됨
user.settings.theme = 'dark';

// 전체 교체 → 마찬가지로 settings 전체가 UPDATE됨
user.settings = { ...user.settings, theme: 'dark' };

JSON 객체가 매우 크다면(수십 KB 이상) 이 점이 성능에 영향을 줄 수 있다. 그런 경우에는 별도 테이블로 분리하는 것을 고려해야 한다.


실전 활용 패턴

패턴 1: 사용자 설정 (유연한 스키마)

가장 흔한 활용이다. 설정 항목이 자주 추가/변경되는 경우, 매번 마이그레이션을 만드는 대신 JSON 컬럼 하나로 관리한다.

typescript
interface AppSettings {
  theme: 'light' | 'dark';
  fontSize: number;
  sidebar: {
    collapsed: boolean;
    width: number;
  };
  shortcuts: Record<string, string>;
}

@Entity()
export class UserPreference {

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

  @Property({ type: 'json' })
  settings: AppSettings = {
    theme: 'light',
    fontSize: 14,
    sidebar: { collapsed: false, width: 240 },
    shortcuts: {},
  };

}

새 설정 항목을 추가할 때 DB 마이그레이션이 필요 없다. TypeScript 인터페이스만 업데이트하면 된다. 다만 기존 레코드에는 새 필드가 없으므로, 코드에서 기본값 처리를 해줘야 한다.

typescript
// 기본값 병합 유틸리티
function withDefaults(saved: Partial<AppSettings>): AppSettings {
  return {
    theme: 'light',
    fontSize: 14,
    sidebar: { collapsed: false, width: 240 },
    shortcuts: {},
    ...saved,
    sidebar: {
      collapsed: false,
      width: 240,
      ...saved.sidebar,
    },
  };
}

패턴 2: 다국어 텍스트

고정된 컬럼 대신 JSON으로 다국어 텍스트를 저장하면 지원 언어를 자유롭게 확장할 수 있다.

typescript
type LocalizedText = Record<string, string>;

@Entity()
export class Product {

  @Property({ type: 'json' })
  name!: LocalizedText;
  // { ko: '키보드', en: 'Keyboard', ja: 'キーボード' }

  @Property({ type: 'json' })
  description!: LocalizedText;

}
typescript
// 현재 로케일에 맞는 텍스트 조회
const product = await em.findOneOrFail(Product, id);
const name = product.name[currentLocale] ?? product.name['ko'];

패턴 3: 이벤트/감사 로그의 payload

이벤트 종류마다 payload 구조가 다른 경우, 타입별로 테이블을 만드는 대신 JSON 컬럼으로 통합한다.

typescript
interface AuditPayload {
  [key: string]: unknown;
}

@Entity()
@Index({ properties: 'payload.targetId' })
export class AuditLog {

  @PrimaryKey()
  id!: number;

  @Enum(() => AuditAction)
  action!: AuditAction;

  @Property({ type: 'json' })
  payload!: AuditPayload;

  @Property()
  createdAt: Date = new Date();

}
typescript
// 사용 예
await em.persistAndFlush(
  em.create(AuditLog, {
    action: AuditAction.USER_UPDATED,
    payload: {
      targetId: user.id,
      changes: { theme: { from: 'light', to: 'dark' } },
    },
  }),
);

JSON 컬럼을 쓰면 안 되는 경우

JSON이 만능은 아니다. 다음 경우에는 정규화된 테이블이 낫다.

자주 JOIN이 필요한 관계 데이터. JSON 안의 ID 배열로 관계를 표현하면 FK 제약 조건이 없어서 데이터 무결성을 보장할 수 없고, JOIN도 비효율적이다.

대부분의 쿼리가 JSON 내부 필드를 기준으로 필터/정렬하는 경우. B-tree 인덱스로 커버할 수 있는 범위가 제한적이라 복잡한 쿼리 성능이 떨어진다. WHERE, ORDER BY, GROUP BY에 JSON 필드가 자주 등장하면 정규 컬럼으로 빼는 게 낫다.

값의 크기가 매우 큰 경우. 앞서 말한 것처럼 JSON 컬럼은 부분 업데이트가 안 된다. 속성 하나를 바꿔도 전체 JSON이 다시 쓰여진다. 수십 KB 이상이면 성능 이슈가 생길 수 있다.

스키마 검증이 중요한 경우. JSON은 DB 레벨에서 스키마 검증을 하지 않는다. CHECK 제약 조건으로 부분적으로 검증할 수는 있지만 한계가 있다. 데이터 무결성이 핵심이라면 정규 컬럼을 쓰자.


정리

기능방법
JSON 컬럼 정의@Property({ type: 'json' })
타입 안전성TypeScript 인터페이스 지정
내부 속성 쿼리객체 구조 그대로 사용 (자동 캐스팅)
필드 인덱스@Index({ properties: 'field.path' })
GIN 인덱스마이그레이션에서 직접 SQL
변경 감지자동 (deep comparison)

JSON 컬럼은 "구조가 유동적이고, 주로 통째로 읽고 쓰는 데이터"에 적합하다. 설정값, 메타데이터, 다국어 텍스트, 이벤트 payload 같은 경우가 대표적이다. 하지만 관계 데이터나 자주 쿼리/정렬하는 데이터에는 정규화가 여전히 정답이다. 도구의 장단점을 알고 상황에 맞게 선택하는 게 핵심이다.

관련 문서